Skip to content

feat(procaptcha): replace React with vanilla TS/DOM in the widget - #2991

Open
HughParry wants to merge 8 commits into
mainfrom
feat/vanilla-widget-drop-react
Open

feat(procaptcha): replace React with vanilla TS/DOM in the widget#2991
HughParry wants to merge 8 commits into
mainfrom
feat/vanilla-widget-drop-react

Conversation

@HughParry

@HughParry HughParry commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Removes React, react-dom and Emotion from the widget runtime. The Managers were already framework-free, so this is a view-layer port: plain DOM with a mount/update/destroy contract, a small observable store in place of useProcaptcha, and microtask-batched renders in place of the React scheduler.

@prosopo/procaptcha-react keeps its name here to avoid a rename ripple across the repo — it no longer contains React and should be renamed separately.

Bundle size (measured, production build, web2 path excl. web3Chunk)

raw gzipped
before 1004.79 kB 301.17 kB
after 773.03 kB 235.90 kB
delta -231.76 kB -65.27 kB (-21.7%)

The React + Emotion chunk (217 kB raw / 59.4 kB gz) is gone entirely. No react-dom, createRoot, @emotion/react or unstable_scheduleCallback remain in any emitted chunk.

Pros

  • ~59 kB gz off the critical path, and more importantly 217 kB less to parse and execute on the main thread before the widget is interactive — on every page that embeds us, including low-end Android.
  • Puzzle drag is materially faster. PuzzleCanvas used to setState on every pointermove (full render + reconciliation per frame); it now writes left/top directly.
  • No React runtime injected into customer pages — no version skew with the host's React, no dev-tools hooks.
  • Emotion gone. @emotion/styled was used in exactly one place; everything else was already inline style objects. Static CSS in the shadow root is smaller and faster than runtime style injection.
  • Several React-era workarounds are now unnecessary: the useRef(Manager(...)) dance that existed because re-renders lost the checkbox click coordinates, and the frictionless started-for-identity guard that existed to tame re-render churn.

Cons

  • We now own reconciliation. Mostly trivial, with one exception that matters: the image grid rebuilds tiles only when the round's Captcha changes identity, so selection toggles don't restart image loads. That's hand-written and tested rather than free.
  • Teardown is manual and must be complete. Nothing cleans up the modal portal, the light-DOM honeypot or document-level listeners for us. The frictionless restart path remounts the whole widget, so a missed listener leaks per restart. Covered by a Teardown collector plus explicit destroy tests.
  • The recovery paths were subtle to port. Session-invalidation, coordinate preservation and the PoW escalation handoff carry hard-won behaviour; procaptcha-frictionless previously had zero coverage on the orchestrator and now has 38 tests.
  • JSX was more readable than imperative DOM for the image grid. There's a real risk of drifting toward a mini-framework — worth watching in review.
  • This does not touch the biggest chunk. fingerprint + util-crypto is 79 kB gz and i18nBackend 41 kB gz; together they're larger than what this removes.

Behaviour

Ported 1:1. Three deliberate exceptions, all called out in code comments:

  1. Fixed: createRoot().render() used to empty the mount point on first commit, which is what removed the skeleton's placeholder spinner. Mounting appends, so this needed an explicit clearElement — without it the skeleton spinner sat next to the real checkbox.
  2. Fixed: the checkbox carried a &:before { content: '""' } rule wrapped in stray braces, so Emotion emitted it with an empty selector and every browser dropped it. Porting it faithfully made it valid for the first time and painted literal quote marks inside the box. Removed rather than reproduced — it was an empty absolutely-positioned pseudo-element that did nothing.
  3. Preserved, not fixed: the modal's inner-panel Emotion rules have never applied (cache container is the checkbox shadow root, element is portalled to document.body). Centering has always come from the outer flex box. Reproduced as-is; worth a separate decision.

Verification

  • 945 unit tests across the seven packages, all with --typecheck clean. The existing suites are ported rather than thinned, plus new coverage for the frictionless orchestrator and component teardown.
  • Driven in a real browser against a local provider: frictionless and PoW verify; image renders a 9-tile challenge with select/deselect and no image reload on selection change; puzzle drag solves; invisible mode returns a valid token via execute(). Zero page errors across all 11 non-web3 demo pages.
  • package-lock.json has been reconciled in place (7636724), not regenerated — regenerating on macOS strips the Linux rollup natives.

Review follow-ups (394bf34)

Four things review turned up, all fixed on top:

  1. The image grid lost its vertical padding. The React root div carried paddingTop/paddingBottom of theme.spacing.unit (10px); the ported grid didn't, and nothing else pads between the grid and the header above or the button row below. This was the one place the "ported 1:1" claim above didn't hold. Restored as a theme-derived gridStyle, re-applied on rebuild, with regression tests.
  2. injectStyle wasn't reference-counted. A second caller for the same id got a no-op disposer, but the first caller's disposer removed the shared tag — so whichever component was destroyed first stripped the CSS from its still-mounted siblings. Not reachable today (one shadow root per checkbox, and clearSlot() destroys before mounting), but the doc comment promised sharing the disposer couldn't honour. Now counted, with idempotent disposers.
  3. The checkbox baked its theme into the injected sheet at mount. update() re-applied the inline box styles but never re-injected, leaving label colour, font and focus ring on the mount-time theme where Emotion regenerated them per render. The sheet id is now scoped by palette.mode and swapped on update.
  4. renderCaptcha kept a dead settings parameter. It existed for identifierPrefix and emotionCacheKey, both gone with React; the remaining webComponentTag was never read there. RenderSettings removed.

Also restores the packages/fingerprintjs submodule pointer to 0467ba6 — both merges from main kept this branch's older e3135f4, which would have silently reverted #3051 (the vitest 4.1.10 bump) on merge.

Known, not fixed here

resetState(0) in the frictionless orchestrator doesn't reset the attempt counter: attemptCount || state.attemptCount falls through to the current count when passed 0, and both restartComponentTimeout and onSessionInvalidated call it expecting a reset. This is character-for-character what ProcaptchaFrictionless.tsx does on main, so the port is faithful and the fix belongs in its own PR — flagging it because the new tests currently lock the existing behaviour in.

HughParry and others added 4 commits August 5, 2026 12:49
Removes react, react-dom and @emotion from the widget's runtime. The
Managers were already framework-free; this ports the view layer to plain
DOM with a mount/update/destroy contract, a small observable store in
place of useProcaptcha, and microtask-batched renders in place of the
React scheduler.

Drops 65.27 kB gzipped (-21.7%) from the web2 bundle path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EHQkKUZFkBZdBXk9ps1C6W
Ports main's Material 3 restyle (#2798), the trusted-event gate
(isEventTrusted), the selectable error label and the remounting reset()
onto the vanilla DOM components, and re-points the locale and bundle
tests that main added at the modules that replaced their React originals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdXVDYG6wybfWpMmAhqDpC
The rewrite moved the checkbox, reload button, honeypot, test-mode banner
and the DOM/state primitives they are built on into this package, where
nothing exercised them directly (41% statement coverage, 0% on every new
module). Adds suites for each, driving real DOM events so the trusted-input
gate is exercised the way a solver would meet it. Coverage is now 97.8%.

Also documents the collector's teardown limitation: startCollector attaches
anonymous listeners to the dapp's form and returns nothing, so they cannot
be detached from the component.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdXVDYG6wybfWpMmAhqDpC
@HughParry
HughParry marked this pull request as ready for review August 13, 2026 10:55
HughParry and others added 4 commits August 13, 2026 11:58
The merge kept main's lock entries for the widget workspaces, which still
listed react, react-dom, @emotion and csstype as dependencies, so `npm ci`
refused to install and every CI job failed at the install step. Regenerated
with `npm install --package-lock-only`.

Also retires four comments that still described the widgets as React
components.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdXVDYG6wybfWpMmAhqDpC
…act drop

Review follow-ups on the vanilla widget port.

The image grid lost `paddingTop`/`paddingBottom` (`theme.spacing.unit`, 10px)
in the port, so the tiles butted straight up against the instruction header
and the button row — neither of which pads against the grid. Restored as a
theme-derived `gridStyle`, re-applied on rebuild so a theme change keeps it.

`injectStyle` handed a no-op disposer to the second caller for an id but let
the first caller's disposer remove the shared tag, so whichever component was
destroyed first stripped the CSS out from under its still-mounted siblings.
The tag is now reference-counted and each disposer is idempotent.

The checkbox baked the theme into its stylesheet at mount and never re-injected,
leaving label colour, font and focus ring on the mount-time theme after a swap.
The sheet id is now scoped by `palette.mode` and swapped on update.

Dropped `RenderSettings` from `renderCaptcha`: it existed for `identifierPrefix`
and `emotionCacheKey`, both gone with React, and the remaining `webComponentTag`
was never read there.

Also restores the `packages/fingerprintjs` pointer to 0467ba6 (main's #3051
vitest 4.1.10 bump); both merges from main had kept the branch's older commit,
which would have reverted it on merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TdXVDYG6wybfWpMmAhqDpC
Restoring the submodule pointer to 0467ba6 moved @prosopo/fingerprintjs onto
vitest 4.1.10 and @prosopo/config 3.3.3, but the lockfile still described the
subtree as it stood at e3135f4, so `npm ci` failed EUSAGE on every job.

Ported main's `packages/fingerprintjs*` entries in place rather than
regenerating: the whole delta is confined to that subtree, and a regen on
macOS would strip the Linux rollup natives.

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

Copy link
Copy Markdown
Contributor

No updates since 2026-08-13T14:31:33Z (over 1 days), so this PR has been converted to draft. That stops it holding CI runners.

Nothing is lost — gh pr ready 2991, or the "Ready for review" button, picks it straight back up. Add the keep-ready label to exempt it permanently.

Converting to draft disables auto-merge, so PRs with auto-merge enabled are excluded from this entirely.

@github-actions

Copy link
Copy Markdown
Contributor

No updates since 2026-08-15T06:03:28Z (over 1 days), so this PR has been converted to draft. That stops it holding CI runners.

Nothing is lost — gh pr ready 2991, or the "Ready for review" button, picks it straight back up. Add the keep-ready label to exempt it permanently.

Converting to draft disables auto-merge, so PRs with auto-merge enabled are excluded from this entirely.

@github-actions

Copy link
Copy Markdown
Contributor

No updates since 2026-08-16T12:02:29Z (over 1 days), so this PR has been converted to draft. That stops it holding CI runners.

Nothing is lost — gh pr ready 2991, or the "Ready for review" button, picks it straight back up. Add the keep-ready label to exempt it permanently.

Converting to draft disables auto-merge, so PRs with auto-merge enabled are excluded from this entirely.

@github-actions

Copy link
Copy Markdown
Contributor

No updates since 2026-08-17T18:05:30Z (over 1 days), so this PR has been converted to draft. That stops it holding CI runners.

Nothing is lost — gh pr ready 2991, or the "Ready for review" button, picks it straight back up. Add the keep-ready label to exempt it permanently.

Converting to draft disables auto-merge, so PRs with auto-merge enabled are excluded from this entirely.

@github-actions

Copy link
Copy Markdown
Contributor

No updates since 2026-08-19T00:03:02Z (over 1 days), so this PR has been converted to draft. That stops it holding CI runners.

Nothing is lost — gh pr ready 2991, or the "Ready for review" button, picks it straight back up. Add the keep-ready label to exempt it permanently.

Converting to draft disables auto-merge, so PRs with auto-merge enabled are excluded from this entirely.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant