Skip to content

[plasmicpkgs] Fix Embed HTML crash when a <script> is detached mid-rehydration#162

Open
jlubawy wants to merge 3 commits into
plasmicapp:masterfrom
jlubawy:fix/embed-html-script-rehydration-race
Open

[plasmicpkgs] Fix Embed HTML crash when a <script> is detached mid-rehydration#162
jlubawy wants to merge 3 commits into
plasmicapp:masterfrom
jlubawy:fix/embed-html-script-rehydration-race

Conversation

@jlubawy

@jlubawy jlubawy commented Jul 7, 2026

Copy link
Copy Markdown

Problem

Embed (Embed HTML) re-executes embedded <script> tags in an async loop, awaiting each external (src) script's load event before continuing. Every iteration asserts:

ensure(oldScript.parentNode).replaceChild(newScript, oldScript);

If a re-render occurs while an earlier script's load is being awaited, React re-applies dangerouslySetInnerHTML and detaches the original <script> nodes that were captured at the start of the loop. On the next iteration oldScript.parentNode is null, so ensure() throws:

Value must not be undefined or null

Because the throw happens in a post-await microtask it becomes an unhandled promise rejection, which is reported as an "application error" even though the page content already rendered. It reproduces reliably on a page whose embed loads an external script (e.g. an analytics/tag loader such as CallRail): the first src script's awaited load opens the window, and the next iteration throws.

While here, I also noticed the cleanup function was returned from the async IIFE rather than from the useEffect callback, so it was never registered — cleanup never became true and the existing post-await guard was dead code.

Fix

  • Skip <script> nodes whose parentNode has become null (detached by a re-render) instead of asserting on them.
  • Bail out of the loop when the effect cleanup has run (unmount / dependency change).
  • Actually register the cleanup by returning it from useEffect (not the async IIFE), so the post-await guard works as intended.
  • Capture rootElt.current once and guard it, dropping the now-unused ensure.

No behavior change on the happy path — the same scripts are rehydrated in the same order; the loop now just tolerates a tree that React swapped out from under it instead of throwing.

Notes

Verified against a live site that embeds an external script: without the fix the page throws the assertion as an unhandled rejection on load; with the fix the rehydration completes (or cleanly stops when the DOM is replaced) and no error is thrown.

…id-rehydration

The Embed component re-executes embedded <script> tags in an async loop,
awaiting each external (src) script's load event before moving on. It asserts
`ensure(oldScript.parentNode).replaceChild(...)` on every iteration.

If a re-render happens while an earlier script's load is being awaited, React
re-applies `dangerouslySetInnerHTML` and detaches the original <script> nodes
captured at the start of the loop. On the next iteration `oldScript.parentNode`
is null, so `ensure()` throws "Value must not be undefined or null" — an
uncaught promise rejection (it's post-await), which surfaces as an "application
error" even though page content already rendered. This reproduces reliably on a
page whose embed contains an external script (e.g. an analytics loader).

Fixes:
- Skip <script> nodes whose parentNode has become null instead of asserting.
- Bail out of the loop when the effect's cleanup has run (unmount / dep change).
- Actually register the cleanup: it was previously returned from the async IIFE
  rather than the useEffect callback, so `cleanup` was never set to true and the
  post-await guard was dead code.
- Capture `rootElt.current` once and guard it, dropping the now-unused `ensure`.

No behavior change on the happy path.
@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown

@jlubawy is attempting to deploy a commit to the Plasmic Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@jlubawy

jlubawy commented Jul 7, 2026

Copy link
Copy Markdown
Author

I have read, agree to, and hereby sign Plasmic's Individual Contributor License Agreement

jlubawy and others added 2 commits July 7, 2026 11:02
Adds a Storybook interaction story that reproduces the Embed HTML crash where
the rehydration loop throws "Value must not be undefined or null" when the code
prop changes while an earlier external script's load event is still pending.

The story uses the existing generic MSW-mocked scripts (example.com/scriptN.js):
it waits until script1 is loading, changes the code prop (detaching the captured
<script> nodes), and asserts no unhandled rejection occurs once script1's load
resolves and the loop advances to the detached node.

Expected to fail on the current implementation (reproducing the bug) and to pass
once the rehydration loop tolerates detached nodes / cancels on cleanup.
The script-rehydration race is now fixed, so reframe the story's doc
comment as a passing regression test instead of an expected-to-fail repro.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jlubawy jlubawy marked this pull request as draft July 7, 2026 20:35
@jlubawy jlubawy marked this pull request as ready for review July 7, 2026 20:35
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