Skip to content

[Perf Tracks] Prevent crash when accessing $$typeof - #35679

Merged
eps1lon merged 4 commits into
react:mainfrom
eps1lon:sebbie/02-03-_perf_tracks_skip_diffing_html-like_objects
Feb 3, 2026
Merged

[Perf Tracks] Prevent crash when accessing $$typeof#35679
eps1lon merged 4 commits into
react:mainfrom
eps1lon:sebbie/02-03-_perf_tracks_skip_diffing_html-like_objects

Conversation

@eps1lon

@eps1lon eps1lon commented Feb 3, 2026

Copy link
Copy Markdown
Collaborator

Alternate to #34840

Fixes #34840
Closes #34840

Summary

Guards dotting into $$typeof unconditionally. The try-catch proposed in #34840 feels too heavy.

Original plan was to skip diffing DOM nodes entirely since they're mutable boxes anyway. That felt like too much of a stretch unless we move the check to the host config. However, that wouldn't fix the linked issue. The linked issue is caused by having a cross-origin window in props not the iframe. Perf tracks wouldn't diff contentWindow since it's not enumerable.

The proposed implementation is host agnostic and feels like a better fit overall. And it guards against cases where accessing an unknown $$typeof property would also throw.

Test plan

@meta-cla meta-cla Bot added the CLA Signed label Feb 3, 2026
@github-actions github-actions Bot added the React Core Team Opened by a member of the React Core Team label Feb 3, 2026
Comment thread packages/shared/ReactPerformanceTrackProperties.js
@react-sizebot

react-sizebot commented Feb 3, 2026

Copy link
Copy Markdown

Comparing: d4a325d...45ac560

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 6.84 kB 6.84 kB +0.05% 1.88 kB 1.88 kB
oss-stable/react-dom/cjs/react-dom-client.production.js = 609.91 kB 609.58 kB = 107.85 kB 107.80 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 6.84 kB 6.84 kB +0.05% 1.88 kB 1.88 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js = 675.84 kB 675.51 kB = 118.81 kB 118.75 kB
facebook-www/ReactDOM-prod.classic.js = 695.47 kB 695.14 kB = 122.24 kB 122.19 kB
facebook-www/ReactDOM-prod.modern.js = 685.85 kB 685.52 kB = 120.63 kB 120.59 kB
oss-experimental/react-noop-renderer/cjs/react-noop-renderer-flight-client.production.js = 2.32 kB 2.09 kB = 0.87 kB 0.78 kB
oss-stable-semver/react-noop-renderer/cjs/react-noop-renderer-flight-client.production.js = 2.32 kB 2.09 kB = 0.87 kB 0.78 kB
oss-stable/react-noop-renderer/cjs/react-noop-renderer-flight-client.production.js = 2.32 kB 2.09 kB = 0.87 kB 0.78 kB
oss-experimental/react-noop-renderer/cjs/react-noop-renderer-flight-client.development.js = 3.08 kB 2.51 kB = 1.08 kB 0.86 kB
oss-stable-semver/react-noop-renderer/cjs/react-noop-renderer-flight-client.development.js = 3.08 kB 2.51 kB = 1.08 kB 0.86 kB
oss-stable/react-noop-renderer/cjs/react-noop-renderer-flight-client.development.js = 3.08 kB 2.51 kB = 1.08 kB 0.86 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
oss-experimental/react-server-dom-parcel/cjs/react-server-dom-parcel-client.browser.development.js = 184.50 kB 183.67 kB = 32.39 kB 32.07 kB
oss-stable/react-server-dom-parcel/cjs/react-server-dom-parcel-client.browser.development.js = 184.49 kB 183.66 kB = 32.39 kB 32.07 kB
oss-stable-semver/react-server-dom-parcel/cjs/react-server-dom-parcel-client.browser.development.js = 184.44 kB 183.61 kB = 32.37 kB 32.04 kB
oss-experimental/react-server-dom-parcel/cjs/react-server-dom-parcel-client.edge.development.js = 185.10 kB 184.26 kB = 32.76 kB 32.49 kB
oss-stable-semver/react-server-dom-parcel/cjs/react-server-dom-parcel-client.edge.development.js = 185.10 kB 184.26 kB = 32.76 kB 32.49 kB
oss-stable/react-server-dom-parcel/cjs/react-server-dom-parcel-client.edge.development.js = 185.10 kB 184.26 kB = 32.76 kB 32.49 kB
oss-experimental/react-server-dom-esm/esm/react-server-dom-esm-client.browser.development.js = 232.86 kB 231.75 kB = 51.60 kB 51.20 kB
oss-stable/react-server-dom-esm/esm/react-server-dom-esm-client.browser.development.js = 232.85 kB 231.74 kB = 51.59 kB 51.19 kB
oss-stable-semver/react-server-dom-esm/esm/react-server-dom-esm-client.browser.development.js = 232.83 kB 231.72 kB = 51.57 kB 51.17 kB
oss-experimental/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js = 189.72 kB 188.77 kB = 33.45 kB 33.13 kB
oss-stable/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js = 189.71 kB 188.75 kB = 33.45 kB 33.12 kB
oss-stable-semver/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js = 189.66 kB 188.70 kB = 33.42 kB 33.10 kB
oss-experimental/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.browser.development.js = 189.10 kB 188.14 kB = 33.29 kB 32.97 kB
oss-stable/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.browser.development.js = 189.08 kB 188.13 kB = 33.28 kB 32.96 kB
oss-stable-semver/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.browser.development.js = 189.03 kB 188.08 kB = 33.26 kB 32.94 kB
oss-experimental/react-server-dom-esm/cjs/react-server-dom-esm-client.browser.development.js = 186.53 kB 185.53 kB = 32.81 kB 32.48 kB
oss-stable/react-server-dom-esm/cjs/react-server-dom-esm-client.browser.development.js = 186.52 kB 185.52 kB = 32.81 kB 32.48 kB
oss-stable-semver/react-server-dom-esm/cjs/react-server-dom-esm-client.browser.development.js = 186.47 kB 185.47 kB = 32.79 kB 32.45 kB
oss-experimental/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.development.js = 188.47 kB 187.43 kB = 33.30 kB 33.00 kB
oss-stable-semver/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.development.js = 188.47 kB 187.43 kB = 33.30 kB 33.00 kB
oss-stable/react-server-dom-webpack/cjs/react-server-dom-webpack-client.edge.development.js = 188.47 kB 187.43 kB = 33.30 kB 33.00 kB
oss-experimental/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.edge.development.js = 188.45 kB 187.41 kB = 33.28 kB 32.98 kB
oss-stable-semver/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.edge.development.js = 188.45 kB 187.41 kB = 33.28 kB 32.98 kB
oss-stable/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.edge.development.js = 188.45 kB 187.41 kB = 33.28 kB 32.98 kB
oss-experimental/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.development.js = 194.77 kB 193.48 kB = 34.08 kB 33.73 kB
oss-stable-semver/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.development.js = 194.77 kB 193.48 kB = 34.08 kB 33.73 kB
oss-stable/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.development.js = 194.77 kB 193.48 kB = 34.08 kB 33.73 kB
oss-experimental/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.node.development.js = 194.75 kB 193.46 kB = 34.06 kB 33.71 kB
oss-stable-semver/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.node.development.js = 194.75 kB 193.46 kB = 34.06 kB 33.71 kB
oss-stable/react-server-dom-turbopack/cjs/react-server-dom-turbopack-client.node.development.js = 194.75 kB 193.46 kB = 34.06 kB 33.71 kB
oss-experimental/react-server-dom-unbundled/cjs/react-server-dom-unbundled-client.node.development.js = 193.35 kB 192.06 kB = 33.79 kB 33.45 kB
oss-stable-semver/react-server-dom-unbundled/cjs/react-server-dom-unbundled-client.node.development.js = 193.35 kB 192.06 kB = 33.79 kB 33.45 kB
oss-stable/react-server-dom-unbundled/cjs/react-server-dom-unbundled-client.node.development.js = 193.35 kB 192.06 kB = 33.79 kB 33.45 kB
oss-experimental/react-server-dom-parcel/cjs/react-server-dom-parcel-client.node.development.js = 191.70 kB 190.41 kB = 33.54 kB 33.19 kB
oss-stable-semver/react-server-dom-parcel/cjs/react-server-dom-parcel-client.node.development.js = 191.70 kB 190.41 kB = 33.54 kB 33.19 kB
oss-stable/react-server-dom-parcel/cjs/react-server-dom-parcel-client.node.development.js = 191.70 kB 190.41 kB = 33.54 kB 33.19 kB
oss-experimental/react-server-dom-esm/cjs/react-server-dom-esm-client.node.development.js = 189.82 kB 188.51 kB = 33.43 kB 33.08 kB
oss-stable-semver/react-server-dom-esm/cjs/react-server-dom-esm-client.node.development.js = 189.82 kB 188.51 kB = 33.43 kB 33.08 kB
oss-stable/react-server-dom-esm/cjs/react-server-dom-esm-client.node.development.js = 189.82 kB 188.51 kB = 33.43 kB 33.08 kB
test_utils/ReactAllWarnings.js = 67.70 kB 66.76 kB = 17.05 kB 16.83 kB
oss-experimental/react-noop-renderer/cjs/react-noop-renderer-flight-client.production.js = 2.32 kB 2.09 kB = 0.87 kB 0.78 kB
oss-stable-semver/react-noop-renderer/cjs/react-noop-renderer-flight-client.production.js = 2.32 kB 2.09 kB = 0.87 kB 0.78 kB
oss-stable/react-noop-renderer/cjs/react-noop-renderer-flight-client.production.js = 2.32 kB 2.09 kB = 0.87 kB 0.78 kB
oss-experimental/react-noop-renderer/cjs/react-noop-renderer-flight-client.development.js = 3.08 kB 2.51 kB = 1.08 kB 0.86 kB
oss-stable-semver/react-noop-renderer/cjs/react-noop-renderer-flight-client.development.js = 3.08 kB 2.51 kB = 1.08 kB 0.86 kB
oss-stable/react-noop-renderer/cjs/react-noop-renderer-flight-client.development.js = 3.08 kB 2.51 kB = 1.08 kB 0.86 kB

Generated by 🚫 dangerJS against 45ac560

@eps1lon
eps1lon force-pushed the sebbie/02-03-_perf_tracks_skip_diffing_html-like_objects branch 3 times, most recently from 9f43236 to f435c5c Compare February 3, 2026 10:58
@eps1lon
eps1lon force-pushed the sebbie/02-03-_perf_tracks_skip_diffing_html-like_objects branch from f435c5c to 736819e Compare February 3, 2026 10:59
@eps1lon
eps1lon force-pushed the sebbie/02-03-_perf_tracks_skip_diffing_html-like_objects branch from 736819e to 45ac560 Compare February 3, 2026 11:00
@eps1lon
eps1lon requested a review from hoxyq February 3, 2026 11:07
@eps1lon
eps1lon marked this pull request as ready for review February 3, 2026 11:07
@eps1lon
eps1lon merged commit 6853d7a into react:main Feb 3, 2026
237 checks passed
@eps1lon
eps1lon deleted the sebbie/02-03-_perf_tracks_skip_diffing_html-like_objects branch February 3, 2026 16:54
github-actions Bot pushed a commit to code/lib-react that referenced this pull request Feb 9, 2026
github-actions Bot pushed a commit to code/lib-react that referenced this pull request Feb 9, 2026
mbret added a commit to mbret/oboku that referenced this pull request Jul 25, 2026
…n frames

React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.

Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit to mbret/oboku that referenced this pull request Jul 25, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)

* feat: upgrade @prose-reader/* to 1.332.0

Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.

Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
  and mount(containerElement) is a one-shot DOM attachment; reader.load()
  is gone. useCreateReader now creates + mounts + destroys the reader in a
  single effect (destroy() is the true inverse of create + mount, so the
  effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
  progress writes flowing back into the book query never destroy/recreate
  the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
  loading overlay now keys off !mounted.

Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
  $$typeof on every object, throwing SecurityError on our gapi cross-origin
  iframe (react/react#34840). The uncaught
  throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
  the work loop (crash on back navigation). Prod is unaffected; dev is
  unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
  APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
  so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(reader): read book once via enabled gate instead of useLiveRef

The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: unpin apps/web react and bump react to 19.2.7 project-wide

apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.

Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)

Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:

- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads


Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5

Co-authored-by: Claude <noreply@anthropic.com>

* fix: lock

* fix: biome

* fix: types

* feat: migrate to pnpm

* fix: dedupe

* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames

React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.

Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer

Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(vercel): bootstrap pnpm 11 in install command to match lockfile

Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(vercel): invoke bootstrapped pnpm by absolute path

Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit to mbret/oboku that referenced this pull request Jul 26, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)

* feat: upgrade @prose-reader/* to 1.332.0

Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.

Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
  and mount(containerElement) is a one-shot DOM attachment; reader.load()
  is gone. useCreateReader now creates + mounts + destroys the reader in a
  single effect (destroy() is the true inverse of create + mount, so the
  effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
  progress writes flowing back into the book query never destroy/recreate
  the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
  loading overlay now keys off !mounted.

Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
  $$typeof on every object, throwing SecurityError on our gapi cross-origin
  iframe (react/react#34840). The uncaught
  throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
  the work loop (crash on back navigation). Prod is unaffected; dev is
  unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
  APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
  so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(reader): read book once via enabled gate instead of useLiveRef

The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: unpin apps/web react and bump react to 19.2.7 project-wide

apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.

Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)

Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:

- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads


Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5

Co-authored-by: Claude <noreply@anthropic.com>

* fix: lock

* fix: biome

* fix: types

* feat: migrate to pnpm

* fix: dedupe

* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames

React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.

Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer

Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(vercel): bootstrap pnpm 11 in install command to match lockfile

Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(vercel): invoke bootstrapped pnpm by absolute path

Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(docker): copy patches into image for frozen install

pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(vercel): set node 24 via engines for web and landing

engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): declare express as a direct dependency

main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(landing): ignore vercel cli .env.local

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reader): freeze first book result to keep reader mounted

useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): derive go-back availability from the router history index (#514)

The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.

react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit to mbret/oboku that referenced this pull request Jul 26, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)

* feat: upgrade @prose-reader/* to 1.332.0

Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.

Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
  and mount(containerElement) is a one-shot DOM attachment; reader.load()
  is gone. useCreateReader now creates + mounts + destroys the reader in a
  single effect (destroy() is the true inverse of create + mount, so the
  effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
  progress writes flowing back into the book query never destroy/recreate
  the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
  loading overlay now keys off !mounted.

Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
  $$typeof on every object, throwing SecurityError on our gapi cross-origin
  iframe (react/react#34840). The uncaught
  throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
  the work loop (crash on back navigation). Prod is unaffected; dev is
  unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
  APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
  so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(reader): read book once via enabled gate instead of useLiveRef

The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: unpin apps/web react and bump react to 19.2.7 project-wide

apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.

Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)

Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:

- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads


Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5

Co-authored-by: Claude <noreply@anthropic.com>

* fix: lock

* fix: biome

* fix: types

* feat: migrate to pnpm

* fix: dedupe

* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames

React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.

Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer

Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(vercel): bootstrap pnpm 11 in install command to match lockfile

Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(vercel): invoke bootstrapped pnpm by absolute path

Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(docker): copy patches into image for frozen install

pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(vercel): set node 24 via engines for web and landing

engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): declare express as a direct dependency

main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(landing): ignore vercel cli .env.local

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reader): freeze first book result to keep reader mounted

useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): derive go-back availability from the router history index (#514)

The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.

react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(web): generalize first-result snapshot into useEnsureQueryData$

Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism

Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(web): memoize useEnsureQueryData$ snapshot in a private query

Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): default useEnsureQueryData$ to networkMode always

The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit to mbret/oboku that referenced this pull request Jul 26, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)

* feat: upgrade @prose-reader/* to 1.332.0

Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.

Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
  and mount(containerElement) is a one-shot DOM attachment; reader.load()
  is gone. useCreateReader now creates + mounts + destroys the reader in a
  single effect (destroy() is the true inverse of create + mount, so the
  effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
  progress writes flowing back into the book query never destroy/recreate
  the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
  loading overlay now keys off !mounted.

Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
  $$typeof on every object, throwing SecurityError on our gapi cross-origin
  iframe (react/react#34840). The uncaught
  throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
  the work loop (crash on back navigation). Prod is unaffected; dev is
  unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
  APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
  so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(reader): read book once via enabled gate instead of useLiveRef

The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: unpin apps/web react and bump react to 19.2.7 project-wide

apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.

Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)

Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:

- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads


Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5

Co-authored-by: Claude <noreply@anthropic.com>

* fix: lock

* fix: biome

* fix: types

* feat: migrate to pnpm

* fix: dedupe

* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames

React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.

Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer

Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(vercel): bootstrap pnpm 11 in install command to match lockfile

Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(vercel): invoke bootstrapped pnpm by absolute path

Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(docker): copy patches into image for frozen install

pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(vercel): set node 24 via engines for web and landing

engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): declare express as a direct dependency

main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(landing): ignore vercel cli .env.local

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reader): freeze first book result to keep reader mounted

useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): derive go-back availability from the router history index (#514)

The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.

react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(web): generalize first-result snapshot into useEnsureQueryData$

Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism

Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(web): memoize useEnsureQueryData$ snapshot in a private query

Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): default useEnsureQueryData$ to networkMode always

The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): let users protect storage from browser eviction (#516)

Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.

- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
  protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it

The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.

`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit to mbret/oboku that referenced this pull request Aug 1, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)

* feat: upgrade @prose-reader/* to 1.332.0

Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.

Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
  and mount(containerElement) is a one-shot DOM attachment; reader.load()
  is gone. useCreateReader now creates + mounts + destroys the reader in a
  single effect (destroy() is the true inverse of create + mount, so the
  effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
  progress writes flowing back into the book query never destroy/recreate
  the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
  loading overlay now keys off !mounted.

Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
  $$typeof on every object, throwing SecurityError on our gapi cross-origin
  iframe (react/react#34840). The uncaught
  throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
  the work loop (crash on back navigation). Prod is unaffected; dev is
  unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
  APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
  so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(reader): read book once via enabled gate instead of useLiveRef

The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: unpin apps/web react and bump react to 19.2.7 project-wide

apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.

Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)

Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:

- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads


Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5

Co-authored-by: Claude <noreply@anthropic.com>

* fix: lock

* fix: biome

* fix: types

* feat: migrate to pnpm

* fix: dedupe

* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames

React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.

Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer

Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(vercel): bootstrap pnpm 11 in install command to match lockfile

Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(vercel): invoke bootstrapped pnpm by absolute path

Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(docker): copy patches into image for frozen install

pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(vercel): set node 24 via engines for web and landing

engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): declare express as a direct dependency

main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(landing): ignore vercel cli .env.local

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reader): freeze first book result to keep reader mounted

useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): derive go-back availability from the router history index (#514)

The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.

react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(web): generalize first-result snapshot into useEnsureQueryData$

Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism

Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(web): memoize useEnsureQueryData$ snapshot in a private query

Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): default useEnsureQueryData$ to networkMode always

The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): let users protect storage from browser eviction (#516)

Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.

- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
  protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it

The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.

`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(shared): match reader file extensions case-insensitively (#508)

isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.


Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ

Co-authored-by: Claude <noreply@anthropic.com>

* refactor: consolidate duplicated web plugin helpers (#506)

Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.


Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(web): consolidate incremental book mutation pipeline (#509)

The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.

Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.


Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn

Co-authored-by: Claude <noreply@anthropic.com>

* fix(web): make the not-interested-only filter match not interested books (#522)

* fix(web): make the not-interested-only filter match not interested books

The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ

* refactor(web): colocate not-interested filter predicate in useCollections

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)

rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.


Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu

Co-authored-by: Claude <noreply@anthropic.com>

* perf: compute book title sort key once per book in alpha sort (#510)

The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.


Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav

Co-authored-by: Claude <noreply@anthropic.com>

* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)

* chore(deps): upgrade workspace dependencies

Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.

dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.

The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph

* refactor(archive-metadata): move the metadata writer out of the src root

`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph

---------

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit to mbret/oboku that referenced this pull request Aug 1, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)

* feat: upgrade @prose-reader/* to 1.332.0

Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.

Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
  and mount(containerElement) is a one-shot DOM attachment; reader.load()
  is gone. useCreateReader now creates + mounts + destroys the reader in a
  single effect (destroy() is the true inverse of create + mount, so the
  effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
  progress writes flowing back into the book query never destroy/recreate
  the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
  loading overlay now keys off !mounted.

Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
  $$typeof on every object, throwing SecurityError on our gapi cross-origin
  iframe (react/react#34840). The uncaught
  throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
  the work loop (crash on back navigation). Prod is unaffected; dev is
  unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
  APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
  so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(reader): read book once via enabled gate instead of useLiveRef

The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: unpin apps/web react and bump react to 19.2.7 project-wide

apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.

Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)

Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:

- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads


Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5

Co-authored-by: Claude <noreply@anthropic.com>

* fix: lock

* fix: biome

* fix: types

* feat: migrate to pnpm

* fix: dedupe

* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames

React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.

Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer

Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(vercel): bootstrap pnpm 11 in install command to match lockfile

Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(vercel): invoke bootstrapped pnpm by absolute path

Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(docker): copy patches into image for frozen install

pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(vercel): set node 24 via engines for web and landing

engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): declare express as a direct dependency

main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(landing): ignore vercel cli .env.local

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reader): freeze first book result to keep reader mounted

useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): derive go-back availability from the router history index (#514)

The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.

react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(web): generalize first-result snapshot into useEnsureQueryData$

Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism

Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(web): memoize useEnsureQueryData$ snapshot in a private query

Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): default useEnsureQueryData$ to networkMode always

The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): let users protect storage from browser eviction (#516)

Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.

- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
  protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it

The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.

`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(shared): match reader file extensions case-insensitively (#508)

isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.


Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ

Co-authored-by: Claude <noreply@anthropic.com>

* refactor: consolidate duplicated web plugin helpers (#506)

Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.


Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(web): consolidate incremental book mutation pipeline (#509)

The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.

Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.


Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn

Co-authored-by: Claude <noreply@anthropic.com>

* fix(web): make the not-interested-only filter match not interested books (#522)

* fix(web): make the not-interested-only filter match not interested books

The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ

* refactor(web): colocate not-interested filter predicate in useCollections

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)

rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.


Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu

Co-authored-by: Claude <noreply@anthropic.com>

* perf: compute book title sort key once per book in alpha sort (#510)

The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.


Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav

Co-authored-by: Claude <noreply@anthropic.com>

* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)

* chore(deps): upgrade workspace dependencies

Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.

dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.

The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph

* refactor(archive-metadata): move the metadata writer out of the src root

`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(docker): copy scripts into the install stage (#528)

The root postinstall runs scripts/check-dexie-version.mjs, but the base
stage only copied manifests, so pnpm install --frozen-lockfile died with
MODULE_NOT_FOUND and every image build failed.


Claude-Session: https://claude.ai/code/session_01S828yJM3UYrYT8CoGsdtxu

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
mbret added a commit to mbret/oboku that referenced this pull request Aug 5, 2026
* feat: upgrade @prose-reader/* to 1.332.0 (#503)

* feat: upgrade @prose-reader/* to 1.332.0

Bump every @prose-reader/* dependency from 1.324.0 to 1.332.0 across web,
api and archive-metadata, and migrate the web reader to the breaking API
changes shipped in that range.

Reader lifecycle (prose #229/#230/#231):
- createReader() now takes the manifest (and optional cfi) at construction
  and mount(containerElement) is a one-shot DOM attachment; reader.load()
  is gone. useCreateReader now creates + mounts + destroys the reader in a
  single effect (destroy() is the true inverse of create + mount, so the
  effect is strict-mode safe), and useLoadReader is removed.
- The restored reading location (cfi) is read through a live ref so later
  progress writes flowing back into the book query never destroy/recreate
  the reader.
- reader.state$ ("idle"|"ready") is replaced by reader.mounted$; the book
  loading overlay now keys off !mounted.

Pin react/react-dom to 19.1.8 in the web app only:
- react-dom 19.2 dev builds recursively read component props and access
  $$typeof on every object, throwing SecurityError on our gapi cross-origin
  iframe (react/react#34840). The uncaught
  throw aborts passive-effect mounts (dead reader quick-menu) and corrupts
  the work loop (crash on back navigation). Prod is unaffected; dev is
  unusable.
- 19.1.x predates the offending logger and the web app uses no 19.2-only
  APIs. admin/landing stay on 19.2.x (@Mantine 9.4 needs useEffectEvent),
  so the pin is web-only, held together by resolve.dedupe in vite.config.
- Documented in AGENTS.md with removal steps for when the react bug is fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(reader): read book once via enabled gate instead of useLiveRef

The enabled predicate already stops observing after the first result, so
progress-sync writes to the same book document never change the value.
Drop the redundant useLiveRef, read bookOnce directly, and name the
enabled predicate to state the intent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: unpin apps/web react and bump react to 19.2.7 project-wide

apps/web was pinned to react 19.1.8 to dodge a dev-only React 19.2 logger
crash with the gapi iframe. The pin diverged web from the rest of the repo
and left @types on 19.2.x, creating a runtime/types skew. Align everything
to the latest 19.2.7 (react/react-dom) and 19.2.17 (@types/react) across
web, admin and landing.

Remove the now-unneeded scaffolding: the vite resolve.dedupe entry and the
AGENTS.md "React version pin" section. The dev-only reader crash returns at
parity with develop/master; production is unaffected (logger is dev-only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* chore(deps): bump @nestjs/platform-express to 11.1.28 to fix multer DoS (#507)

Updates @nestjs/platform-express 11.1.27 -> 11.1.28, which pulls multer
2.1.1 -> 2.2.0 (multer is consumed only by platform-express). Resolves the
two multer denial-of-service advisories on the API file-upload path:

- GHSA-72gw-mp4g-v24j (high): DoS via deeply nested field names
- GHSA-3p4h-7m6x-2hcm (moderate): DoS via incomplete cleanup of aborted uploads


Claude-Session: https://claude.ai/code/session_01WKoPAepDc2xhSFFDhHTHe5

Co-authored-by: Claude <noreply@anthropic.com>

* fix: lock

* fix: biome

* fix: types

* feat: migrate to pnpm

* fix: dedupe

* fix(deps): patch react-dom 19.2.8 dev perf-track crash on cross-origin frames

React 19.2's dev-only performance track deep-walks changed props and
throws a SecurityError when it reaches the reader iframe's cross-origin
window, crashing the commit (react/react#34840). Guard the prop
walkers so unreadable values log as [inaccessible] instead of throwing.

Fixed upstream by react/react#35679 (19.3 canary only); remove with
`pnpm patch-remove react-dom@19.2.8` once on a release containing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dev-proxy): stop 502 bursts from unreachable IPv6 host.docker.internal peer

Docker Desktop writes both IPv4 and IPv6 host-gateway entries, so nginx
expands host.docker.internal into two peers; the IPv6 one is unreachable
from the IPv4-only compose network and its failure accounting could mark
the whole implicit upstream down (no live upstreams -> 502 bursts during
replication). An explicit upstream with max_fails=0 lets a failed connect
fall through to the reachable address instead of disabling the group.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(vercel): bootstrap pnpm 11 in install command to match lockfile

Vercel's lockfile heuristic picks pnpm 9, which can't read patchedDependencies
from pnpm-workspace.yaml and fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
The bootstrapped pnpm 11 defers to the packageManager pin in package.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(vercel): invoke bootstrapped pnpm by absolute path

Vercel prepends its detected pnpm 9 to PATH, shadowing the npm-installed
pnpm 11, so the install still hit ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(docker): copy patches into image for frozen install

pnpm-workspace.yaml references patches/react-dom@19.2.8.patch, so the
frozen install in the base stage fails with ENOENT without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(vercel): set node 24 via engines for web and landing

engines.node overrides the dashboard Node version; >=24 resolves to the
latest 24.x on Vercel while staying satisfied by the node 25 used in CI,
docker and local shells.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): declare express as a direct dependency

main.ts value-imports json/urlencoded from express, which pnpm's
isolated node_modules can't resolve through @nestjs/platform-express;
dist/main crashed with MODULE_NOT_FOUND on a clean install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(landing): ignore vercel cli .env.local

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reader): freeze first book result to keep reader mounted

useBook is a live rxdb-backed query and the book finished dialog keeps
an always-on observer of the same key, so the disabled observer in
useCreateReader still received every progress-sync cache update and the
bookOnce effect dependency remounted the reader on each write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): derive go-back availability from the router history index (#514)

The back arrow fell through to home whenever a screen had performed a
replace-only navigation, such as switching tab on the book optimize
screen. react-router rebuilds the whole history state object on replace,
so the `__obokuCanGoBack` flag was dropped, and TrackHistoryCanGoBack
only re-stamped it on pathname changes.

react-router already tracks the entry position under `history.state.idx`,
preserved across replace, which removes the need for a custom flag, the
`__obokuFallbackBack` marker and the tracker component.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* feat(web): generalize first-result snapshot into useEnsureQueryData$

Extracts createBookQueryOptions alongside the existing
createLinkQueryOptions pattern and rebuilds the reader's frozen book
snapshot on top of a reusable hook that resolves a query's first result
and ignores later cache updates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(web): rebuild useEnsureQueryData$ on an ensureQueryData$ mechanism

Splits the one-shot idea into an observable flavour of
queryClient.ensureQueryData (cache read, else first emission seeding the
empty cache, never touching the tanstack fetch machinery) and a thin
snapshot hook on top. Drops the inert observer, so consumers no longer
re-render on post-capture cache writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(web): memoize useEnsureQueryData$ snapshot in a private query

Replaces the effect+state capture with a vanilla useQuery over a
prefix-namespaced key whose queryFn is ensureQueryData$: staleTime
Infinity freezes the snapshot, gcTime 0 scopes it to the consumers'
lifetime and concurrent consumers dedupe into a single resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): default useEnsureQueryData$ to networkMode always

The private useQuery dropped the source query's networkMode, so offline
the default online mode paused resolution entirely — blocking even the
warm-cache read and leaving locally available books on the loading
screen. Resolution is cache-first, so always is the correct default;
network-bound sources can pass online explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): let users protect storage from browser eviction (#516)

Origin storage is best-effort by default, so the browser may evict oboku's
whole bucket under disk pressure — the RxDB library, the Dexie downloads and
the covers cache together. Most of it re-syncs from CouchDB afterwards, but
books uploaded straight from a device exist nowhere else and are lost for good.
Nothing in the app ever called `navigator.storage.persist()`, so no user was
protected.

- `useIsStoragePersisted` / `useRequestStoragePersistence` under `storage/`
- a row in Manage storage showing a green or red shield, tappable to request
  protection, with a "Read more" link to the new guide
- an inbox notification while storage is evictable, pointing at that screen
- `guides/storage.md` explaining what it is, why it matters and how to fix it

The inbox notification is deliberately not dismissible: Chrome refuses the
request for most users in a plain tab, and installing the app both satisfies
its heuristics and silences the warning, so the nag and the fix are the same
action.

`LocalNotificationCard` hardcoded a login icon, which only worked while
`session_expired` was the sole local notification; the icon now comes from the
notification's action.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(shared): match reader file extensions case-insensitively (#508)

isFileSupported compared the raw extension against the lowercase
READER_ACCEPTED_EXTENSIONS list, so files like Book.EPUB or comic.CBZ
were reported as unsupported and skipped by synology-drive/webdav sync
and greyed out in file browsers.


Claude-Session: https://claude.ai/code/session_01DqCq7ako5cdiYdvpJAKgKJ

Co-authored-by: Claude <noreply@anthropic.com>

* refactor: consolidate duplicated web plugin helpers (#506)

Unify the three byte-identical connector-based useRefreshMetadata hooks
(server, webdav, synology-drive) behind a shared
createConnectorRefreshMetadata factory in plugins/common, and remove the
orphaned duplicate of the tokenValidity module that was superseded by the
plugins/common copy every caller already imports.


Claude-Session: https://claude.ai/code/session_01TznNj9nDaE3ezbXP9ssXTa

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(web): consolidate incremental book mutation pipeline (#509)

The three incremental book mutation hooks (useIncrementalBookModify,
useIncrementalBookPatch, useIncrementalBookUpdate) each duplicated the
same document-resolution pipeline: resolve a book doc from either an id
or a document, guard against a missing document, then apply the rxdb
incremental operation. Only the operation itself differed.

Extract that shared pipeline into incrementalBookMutation and have each
hook pass its operation as a callback. Public hook APIs are unchanged.


Claude-Session: https://claude.ai/code/session_01MjDUJXpyhzCwpJJ73YcGdn

Co-authored-by: Claude <noreply@anthropic.com>

* fix(web): make the not-interested-only filter match not interested books (#522)

* fix(web): make the not-interested-only filter match not interested books

The isNotInterested: "only" branch in useCollections intersected
collection.books with the bookIds query param instead of the computed
notInterestedBookIds. The search screen never passes bookIds, so
intersection(collection.books, undefined) degenerated to the full book
list and the "Show not interested contents: Only" filter returned every
non-empty collection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BKdyaYuEezwdEQ8XCJZdQ

* refactor(web): colocate not-interested filter predicate in useCollections

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EveXE8CpZGCHmoSJwWHqdd

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deps): bump rxdb to 17.4.0 to fix ws DoS advisory (#511)

rxdb 17.3.0 pinned ws@8.20.1, which is affected by GHSA-96hv-2xvq-fx4p
(high-severity memory-exhaustion DoS). rxdb 17.4.0 requires ws@8.21.0,
clearing the advisory in @oboku/web. Patch bump within the existing
^17 range; the lockfile also dedupes seven nested transitive copies that
rxdb 17.4.0 no longer pins.


Claude-Session: https://claude.ai/code/session_01KN1rjmw23NgWRyXVvuBiqu

Co-authored-by: Claude <noreply@anthropic.com>

* perf: compute book title sort key once per book in alpha sort (#510)

The alpha branch of sortBooksBy called getMetadataFromBook twice inside
the sort comparator, so an expensive metadata merge ran ~2·n·log(n) times
per sort. Precompute each book's title once (n calls) via a
decorate-sort-undecorate, turning the getMetadataFromBook cost from
O(n·log n) into O(n). Output order is unchanged.


Claude-Session: https://claude.ai/code/session_017dkihQVZGC8NhYJzQcdoav

Co-authored-by: Claude <noreply@anthropic.com>

* Upgrade workspace dependencies and read API book metadata through prose-reader (#524)

* chore(deps): upgrade workspace dependencies

Brings prose-reader to 1.334, plus MUI, Sentry, react-query, vite, workbox
and the rest of the tree up to current.

dexie is pinned to the exact version rxdb resolves: the two share a single
IndexedDB connection and break when they drift. A postinstall check fails
the install if they ever disagree again.

The API reads book metadata through prose-reader's resolveArchive rather
than assembling it from the OPF and ComicInfo documents itself, and reports
the sources it could not parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph

* refactor(archive-metadata): move the metadata writer out of the src root

`patchArchiveMetadata` composes the ComicInfo and OPF container modules
the same way those modules compose their own read/write halves, so it
belongs beside them rather than at the package root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQjPncXbBGcwudKbfo7Rph

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix(docker): copy scripts into the install stage (#528)

The root postinstall runs scripts/check-dexie-version.mjs, but the base
stage only copied manifests, so pnpm install --frozen-lockfile died with
MODULE_NOT_FOUND and every image build failed.


Claude-Session: https://claude.ai/code/session_01S828yJM3UYrYT8CoGsdtxu

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(api): consolidate synology-drive link session resolution (#526)

getFileMetadata, getFolderMetadata and download each repeated the same
~18-line preamble: validate connectorId/credentials/db, load the
connector, open a Synology Drive session and read fileId from the link.
Extract it into a single openSynologyDriveSessionForLink helper so the
validation and error handling live in one place.


Claude-Session: https://claude.ai/code/session_01SiouS4DW4vVoe9wfNAN761

Co-authored-by: Claude <noreply@anthropic.com>

* perf: compute search title once and hoist search regex in book search (#525)

useBooksForSearch recomputes on every keystroke over the whole library.
It compiled a fresh RegExp inside the .filter callback (once per book)
and called the expensive getMetadataFromBook twice inside the sort
comparator (~2·n·log(n) metadata merges). Hoist the regex to one
compilation per search and precompute each book's title once (n calls)
via decorate-sort-undecorate. Results and order are unchanged.

Benchmark at 3000 books: 149ms -> 15.5ms per search (~9.6x).


Claude-Session: https://claude.ai/code/session_01WTpoRzFz6L5YQ1MXtHE2Vf

Co-authored-by: Claude <noreply@anthropic.com>

* chore(deps): bump next to 16.2.12 to fix DoS/SSRF/proxy-bypass advisories (#527)

Updates next and its lockstep companion eslint-config-next from 16.1.6 to
16.2.12 in @oboku/landing. Minor bump within major 16; patches ~9 high
severity advisories in the internet-facing landing app (Server Components
DoS, SSRF via WebSocket upgrades, App/Pages Router middleware & proxy
bypasses, i18n bypass).

Both were exact-pinned to 16.1.6; kept exact-pin style and moved them
together since eslint-config-next version-tracks next.


Claude-Session: https://claude.ai/code/session_01W73Y7fJypdhxKPp6YtHi65

Co-authored-by: Claude <noreply@anthropic.com>

* feat: ugprade vercel

* feat: upgrade

* chore(deps): bump sharp to 0.35.3 to fix libvips security advisory (#540)

Updates @oboku/api's sharp dependency from ^0.34.5 to ^0.35.3 to remediate
the high-severity advisory affecting sharp <0.35.0 (inherited libvips CVEs).
sharp 0.35 raises the minimum Node.js to >=20.9.0, satisfied by the pinned
Node 25 runtime. The image pipeline in covers.service.ts uses only stable
APIs (resize, toFormat, jpeg, webp, toBuffer), so no code changes are needed.


Claude-Session: https://claude.ai/code/session_014kuujQqjWVajzvvhrmqyuB

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(web): consolidate duplicated plugin link-info hooks (#539)

The dropbox, google, synology-drive and one-drive plugins each shipped a
byte-identical `useLinkInfo` that derives a `ID: <fileId>` label, and the
server and webdav plugins shipped an identical `filePath`-based one. Move
both implementations into `plugins/common/linkInfo.ts` as
`useFileIdLinkInfo` and `useFilePathLinkInfo`, and point each plugin at the
shared export. Behavior is unchanged; a plugin can still diverge later by
providing its own implementation.


Claude-Session: https://claude.ai/code/session_01KNBTACP3nnNjfsGvFh8i9z

Co-authored-by: Claude <noreply@anthropic.com>

* chore: remove strict dead code (unused exports) (#537)

Remove exports that knip flagged and that a repo-wide grep confirmed are
never imported or referenced anywhere (each appears only at its own
declaration; no dynamic/string/test references):

- apps/web/src/tags/helpers.ts: getProtectedTags, getTagsByIds (both
  @deprecated), useTagsByIds
- apps/web/src/reader/states.ts: reader$, usePagination
- apps/web/src/collections/dbHelpers.ts: getCollections
- apps/api/src/lib/utils.ts: switchMapMergeOuter
- apps/api/src/lib/google/googleBooksApi.ts: findSeriesByTitle
- apps/api/src/lib/couch/dbHelpers.ts: findAllDataSources

Also drop the now-unused type imports left behind (Database, MangoResponse,
DataSourceDocType) and the rxjs/reactjrx imports that only those symbols used.


Claude-Session: https://claude.ai/code/session_013Xb8Rr8xPgnjmexMzNbZBW

Co-authored-by: Claude <noreply@anthropic.com>

* perf(web): linearize covers cache cleanup passes (#538)

The service-worker covers cache cleanup runs every 10 minutes (and on
startup) and had two super-linear passes over the cover cache:

- obsolete-cover detection matched each cache key against the book and
  collection lists with `.some` (O(cacheKeys * (books + collections)))
- outdated-version detection compared every cache key against every
  other key, re-parsing request headers each time (O(cacheKeys^2))

Replace the membership scan with a Set lookup and the pairwise scan with
a single max-time-per-coverId map. Both passes now parse each request's
headers once and produce byte-for-byte identical removal sets.


Claude-Session: https://claude.ai/code/session_01RNbzoWKvYxCqMT7PLKPTHQ

Co-authored-by: Claude <noreply@anthropic.com>

* refactor: single product version, automatic releases on master, versioned docker tags (#531)

* chore: align license metadata with GPL-3.0

The root LICENCE file is the full GNU GPL v3 text, but the README and
package manifests advertised MIT (and UNLICENSED for the API).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa

* ci: publish a GitHub release when the root version changes

Tags were pushed by the web version bump, but no GitHub release was ever
created, so the repository exposed no installable version to pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa

* ci: tag docker images with the version and commit sha

Images were published as :latest only, so an instance could not be pinned
to a known build. Version resolution moves to a shared job now that the
docker builds and the release both need it.

Release notes rewrite changelog links relative to the changelog into blob
URLs at the release tag, since relative targets do not resolve on a
release page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa

* refactor: single product version for the whole repo

The web app carried its own version line, bumped on every master push,
which was accidental rather than intended. Root package.json is now the
only version: the web app reads it through the __APP_VERSION__ build
constant, and the private unpublished manifests no longer declare one.

The persisted query cache moves to __BUILD_ID__ so it keeps busting per
build; keying it on a version that now changes only per release would let
a newer build rehydrate state persisted by an older one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa

* docs: document the release-notes script contract

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa

* refactor: mark workspace packages private and drop their versions

@oboku/shared was last published in 2022 and no publish flow exists, so
these are internal workspace packages like the apps: no version to carry,
and private stops an accidental npm publish.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa

* ci: bump the version automatically on every master push

The release version is now derived from conventional commits since the
last released tag (breaking -> major, feat -> minor, otherwise patch) and
committed back to master, so merging develop is the only manual step.
Downstream jobs build from the bump commit so the web app embeds the
version it ships under. A hand-edited, not-yet-released version in
package.json still wins, which also covers the pending 1.2.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa

* fix: tighten the breaking-change footer detection

The conventional commits spec allows both BREAKING CHANGE and
BREAKING-CHANGE as footer tokens, and requiring the colon stops prose
mentions from triggering a major bump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa

* ci: replace hand-rolled version bump and release with semantic-release

The bump computation, tagging, release creation and notes generation move
to semantic-release with the conventionalcommits preset. A catch-all
releaseRules entry keeps the release-on-every-master-push behavior that
the default rules would skip for chore/docs-only pushes.

Upgrade instructions now travel in BREAKING CHANGE commit footers, which
release-notes-generator renders on the release page, so the gitbook
changelog becomes a pointer to GitHub releases and both custom release
scripts are deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X91x1vJ1CJ45Gwk9WmrFaa

---------

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed React Core Team Opened by a member of the React Core Team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: react-dom-client.development.js tries to read $$typeof on iframe object

3 participants