feat(procaptcha): replace React with vanilla TS/DOM in the widget - #2991
feat(procaptcha): replace React with vanilla TS/DOM in the widget#2991HughParry wants to merge 8 commits into
Conversation
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
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
|
No updates since Nothing is lost — Converting to draft disables auto-merge, so PRs with auto-merge enabled are excluded from this entirely. |
|
No updates since Nothing is lost — Converting to draft disables auto-merge, so PRs with auto-merge enabled are excluded from this entirely. |
|
No updates since Nothing is lost — Converting to draft disables auto-merge, so PRs with auto-merge enabled are excluded from this entirely. |
|
No updates since Nothing is lost — Converting to draft disables auto-merge, so PRs with auto-merge enabled are excluded from this entirely. |
|
No updates since Nothing is lost — Converting to draft disables auto-merge, so PRs with auto-merge enabled are excluded from this entirely. |
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/destroycontract, a small observable store in place ofuseProcaptcha, and microtask-batched renders in place of the React scheduler.@prosopo/procaptcha-reactkeeps 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)The React + Emotion chunk (217 kB raw / 59.4 kB gz) is gone entirely. No
react-dom,createRoot,@emotion/reactorunstable_scheduleCallbackremain in any emitted chunk.Pros
PuzzleCanvasused tosetStateon everypointermove(full render + reconciliation per frame); it now writesleft/topdirectly.@emotion/styledwas 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.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
Captchachanges identity, so selection toggles don't restart image loads. That's hand-written and tested rather than free.Teardowncollector plus explicit destroy tests.procaptcha-frictionlesspreviously had zero coverage on the orchestrator and now has 38 tests.fingerprint + util-cryptois 79 kB gz andi18nBackend41 kB gz; together they're larger than what this removes.Behaviour
Ported 1:1. Three deliberate exceptions, all called out in code comments:
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 explicitclearElement— without it the skeleton spinner sat next to the real checkbox.&: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.document.body). Centering has always come from the outer flex box. Reproduced as-is; worth a separate decision.Verification
--typecheckclean. The existing suites are ported rather than thinned, plus new coverage for the frictionless orchestrator and component teardown.execute(). Zero page errors across all 11 non-web3 demo pages.package-lock.jsonhas 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:
paddingTop/paddingBottomoftheme.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-derivedgridStyle, re-applied on rebuild, with regression tests.injectStylewasn'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, andclearSlot()destroys before mounting), but the doc comment promised sharing the disposer couldn't honour. Now counted, with idempotent disposers.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 bypalette.modeand swapped on update.renderCaptchakept a deadsettingsparameter. It existed foridentifierPrefixandemotionCacheKey, both gone with React; the remainingwebComponentTagwas never read there.RenderSettingsremoved.Also restores the
packages/fingerprintjssubmodule pointer to0467ba6— both merges from main kept this branch's oldere3135f4, 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.attemptCountfalls through to the current count when passed0, and bothrestartComponentTimeoutandonSessionInvalidatedcall it expecting a reset. This is character-for-character whatProcaptchaFrictionless.tsxdoes 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.