Skip to content

RDEV-10097 - Load each script once per document, and release load listeners - #216

Open
helderjgoncalves wants to merge 3 commits into
masterfrom
rdev-0000/release-resource-load-listeners
Open

RDEV-10097 - Load each script once per document, and release load listeners#216
helderjgoncalves wants to merge 3 commits into
masterfrom
rdev-0000/release-resource-load-listeners

Conversation

@helderjgoncalves

@helderjgoncalves helderjgoncalves commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Two fixes to ResourcesLoader, rescued from #208.

1. Scripts were loaded, and re-executed, once per view

Inner views are shadow roots rather than frames — attachShadow in ViewPortal and ViewFrame, with no contentDocument anywhere — and shadow DOM encapsulates styles but not scripts. A <script src> appended for one view has therefore executed for the whole document. loadScript nevertheless tracked loads in a per-view map, falling back to the main frame's map.

That fallback never worked:

const scriptLoadTask = frameScripts.get(scriptSrc) || !view.isMain ? getView(mainFrameName).scriptsLoadTasks.get(scriptSrc) : null;

|| binds tighter than ?:, so this parses as (frameScripts.get(scriptSrc) || !view.isMain) ? mainFrameTask : null. The view's own entry is only ever a truthiness test and the value taken is always the main frame's task. For an inner view !isMain is true, so the condition is constant and the lookup only ever consults the main frame — the entry written for the view itself is unreachable. Inner views re-loaded, and re-executed, scripts that another inner view had already loaded. The cost is not the network request, which the HTTP cache absorbs, but re-running a UMD bundle into the same document.

Replaced with a single document-wide map, which is what the lifetime of a script actually matches, and dropped the now unused scriptsLoadTasks from ViewMetadata. A script that fails is removed so a later view can attempt it again; one that times out is kept, since it may still arrive.

2. Load listeners and timeouts outlived the resource

waitForLoad left its load listener attached to the element after the resource had loaded, and had no error handler at all, so a resource that failed to load kept both the listener and the pending timeout alive for the lifetime of the document. Both are now released as soon as the outcome is known.

Behaviour changes to be aware of

  • loadScript now throws synchronously when view.head is unset, and does so before registering the task. It previously threw after registering, leaving an entry nothing could resolve, and from inside an async promise executor — so the throw became an unhandled rejection and the caller hung. Every call site is async, so it now surfaces as a rejection.
  • The timeout still only warns and tears nothing down, so a resource arriving after the warning resolves as before.
  • onError does not reject. None of the await sites in Bootstrap.ts or Loader.ts handles an error, so a caller awaiting a failed resource still hangs, as it does today. Turning that into a real error is worth doing but needs those call sites audited first.

Not taken from #208

The rest of that PR is either superseded by #211, which covers the ViewFrame / ViewPortal / Loader.View rework behind the getEnsureDisposeInnerViewsFlag() flag, or was debug scaffolding — debugger statements, console.logs and a commented-out render cache. Its version of this fix put the registry on window under two keys, but assigned window[LoadedScriptsKey] in both initialiser branches, so the task map was never actually stored; it also recorded a script as loaded before the load completed, so a failed script was permanently treated as present.

Test plan

  • tsc --noEmit -p ReactViewResources/Loader/tsconfig.json passes (verified locally)
  • Views load scripts and stylesheets normally
  • A second inner view requiring the same dependency does not add a second <script> to the document
  • A resource that 404s leaves no listener on the element and no pending timeout, and can be requested again
  • A slow resource that arrives after the timeout warning still resolves

Important

RDEV-0000 needs replacing with a real ticket before merge — #208 had no ticket to inherit.

waitForLoad left its load listener attached to the script or link element
after the resource had loaded, and had no error handler at all, so a
resource that failed to load kept both the listener and the pending
timeout alive for the lifetime of the document.

Both listeners are now removed and the timeout cleared as soon as the
outcome is known. The timeout still only warns and tears nothing down, so
a resource arriving after the warning resolves as before, and a failed
resource is still not reported back, since no caller handles one today.

Co-authored-by: Cursor <cursoragent@cursor.com>
@helderjgoncalves
helderjgoncalves requested a review from a team as a code owner August 6, 2026 14:55
Inner views are shadow roots rather than frames, and shadow dom
encapsulates styles but not scripts, so a script appended for one view has
executed for the whole document. loadScript nevertheless tracked loads in
a per-view map, with a fallback to the main frame's map.

That fallback never worked. `a || !b ? c : null` parses as
`(a || !b) ? c : null`, so the view's own entry was only ever used as a
truthiness test and the value taken was always the main frame's task. For
an inner view `!isMain` is true, making the condition constant, so the
lookup only ever consulted the main frame and the entry written for the
view itself was unreachable. Inner views therefore re-loaded, and
re-executed, scripts another inner view had already loaded.

Replaced with a single document-wide map, which is what the lifetime of a
script actually matches, and dropped the now unused per-view
scriptsLoadTasks. A script that fails is removed so a later view can
attempt it again; one that times out is kept, since it may still arrive.

The head check now runs before the task is registered. It previously ran
after, leaving behind an entry nothing could resolve, and did so inside an
async promise executor, where the throw became an unhandled rejection and
the caller hung instead. Every call site is async, so it now surfaces as a
rejection.

Co-authored-by: Cursor <cursoragent@cursor.com>
@helderjgoncalves helderjgoncalves changed the title RDEV-0000 - Release resource load listeners and pending timeout RDEV-0000 - Load each script once per document, and release load listeners Aug 6, 2026
Both changes on this branch are bug fixes, which the versioning rules in
the README take as a patch increment.

Co-authored-by: Cursor <cursoragent@cursor.com>
@helderjgoncalves
helderjgoncalves requested review from fmgracias and removed request for tbragaf August 6, 2026 16:06
@helderjgoncalves helderjgoncalves changed the title RDEV-0000 - Load each script once per document, and release load listeners RDEV-10097 - Load each script once per document, and release load listeners Aug 6, 2026
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