Skip to content

feat(scripts): add async readiness and consumer scopes#840

Open
harlan-zw wants to merge 9 commits into
mainfrom
feat/abortable-async-use-script
Open

feat(scripts): add async readiness and consumer scopes#840
harlan-zw wants to merge 9 commits into
mainfrom
feat/abortable-async-use-script

Conversation

@harlan-zw

@harlan-zw harlan-zw commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • let use() await SDK readiness with a lifecycle AbortSignal and a waitFor() helper
  • keep load(), callback replay, and proxy calls pending until the SDK API is actually ready
  • add consumer-owned ScriptScope instances through createScope() and useScriptScope()
  • add onLoadedEffect() for resources whose cleanup belongs to one consumer
  • give each Vue, React, Svelte, Angular, and Solid integration its own scope without mutating the cached script instance
  • preserve callback options and make callback, trigger, and disposer handling safe across cancellation and framework teardown

Why

The script element and SDK API are shared, but a component's callbacks and resources are not. Integrations were rebuilding the same promise, abort, settled-state, callback-restoration, and cleanup machinery, then attaching consumer state to a cached ScriptInstance.

This separates those lifetimes. use({ signal, waitFor }) owns shared script-to-API readiness. A scope owns one consumer's registrations, async effects, abort signal, and disposal. Framework adapters can now bind that scope directly to their native lifecycle.

Validation

  • pnpm test — 203 test files and 1,645 tests passed; 8 skipped
  • workspace lint and typecheck passed
  • full package build passed
  • bundle-size suite passed

Summary by CodeRabbit

  • New Features

    • Added useScriptScope for independently managing shared script consumers and cleanup.
    • Added asynchronous readiness coordination with waitFor() and cancellation support.
    • Added scoped effects with lifecycle-aware cleanup through onLoadedEffect().
    • Added keyed event handler options across supported integrations.
    • Improved script loading, error handling, and cleanup behavior.
  • Documentation

    • Expanded script composable guidance with scope, readiness, triggers, and cleanup examples.
  • Tests

    • Added coverage for scoped lifecycles, async readiness, keyed callbacks, cancellation, and error handling.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Changes

Async script lifecycle

Layer / File(s) Summary
Async useScript contracts
packages/unhead/src/scripts/types.ts, packages/vue/src/scripts/index.ts, packages/unhead/test/unit/scripts/use.test.ts, docs/head/7.api/composables/4.use-script.md
use() accepts asynchronous results and an abort signal; script instances expose lifecycle signals, scopes, and function-trigger types, with related exports, tests, and documentation updated.
Readiness and cancellation lifecycle
packages/unhead/src/scripts/useScript.ts, packages/unhead/src/scripts/waitFor.ts, packages/unhead/test/unit/scripts/events.test.ts, packages/unhead/test/unit/scripts/proxy.test.ts
Loading waits for asynchronous use() and waitFor() results, aborts on errors or removal, routes failures through onError, and replays queued proxy calls after resolution.
Consumer scope lifecycle
packages/unhead/src/scripts/scope.ts, packages/unhead/test/unit/scripts/scope.test.ts
Shared scripts provide independently disposable scopes with scoped callbacks, effects, abort signals, cleanup, and error reporting.
Framework integration and callback forwarding
packages/angular/src/composables.ts, packages/svelte/src/composables.ts, packages/vue/src/scripts/useScript.ts, packages/react/src/composables.ts, packages/solid-js/src/composables.ts, packages/*/test/*
Framework wrappers adopt scoped scripts, forward keyed callback options, preserve callback behavior, and dispose registrations with framework lifecycles.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Consumer
  participant FrameworkComposable
  participant useScriptScope
  participant SharedScript
  participant ScopeEffect
  Consumer->>FrameworkComposable: register scoped callbacks or effects
  FrameworkComposable->>useScriptScope: create consumer scope
  useScriptScope->>SharedScript: share script and register handlers
  SharedScript->>ScopeEffect: invoke onLoadedEffect with signal
  ScopeEffect->>Consumer: return cleanup
  Consumer->>useScriptScope: dispose scope
  useScriptScope->>ScopeEffect: abort and run cleanup
Loading

Possibly related PRs

  • unjs/unhead#807: Related to keyed callback cleanup and script removal lifecycle.
  • unjs/unhead#808: Related to framework callback registration and lifecycle disposal.
  • unjs/unhead#817: Related to the shared useScript lifecycle and registry handling.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 45.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: async readiness plus consumer-scoped script lifecycles.
Description check ✅ Passed The description covers summary, motivation, and validation, but omits the linked issue and change-type checklist sections.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/abortable-async-use-script

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Size

⚠️ 3 bundles grew · net +3.6 kB gz

Bundle Gzipped Δ
Client (Full) 8.5 kB → 9.7 kB 🔴 +1.2 kB (+13.8%)
Vue Client (Full) 9.3 kB → 10.4 kB 🔴 +1.1 kB (+11.9%)
React Client (Full) 9.4 kB → 10.7 kB 🔴 +1.3 kB (+13.7%)
All bundles (14)
Bundle Gzipped Brotli Raw
Core
Client (Minimal) 5.2 kB 4.7 kB 13 kB
Client (Full) 9.7 kB 8.8 kB 25.7 kB 🔴
Client (Self-Contained) 5.5 kB 5 kB 13.7 kB
Server (Minimal) 4.7 kB 4.3 kB 11.8 kB
Server (Self-Contained) 5.1 kB 4.6 kB 12.5 kB
Vue
Vue Client (Minimal) 5.7 kB 5.2 kB 14 kB
Vue Client (Full) 10.4 kB 9.5 kB 27.6 kB 🔴
Vue Server (Minimal) 5.2 kB 4.7 kB 12.9 kB
React
React Client (Minimal) 5.6 kB 5.1 kB 14 kB
React Client (Full) 10.7 kB 9.7 kB 28.5 kB 🔴
React Server (Minimal) 5 kB 4.5 kB 12.5 kB
Schema.org
Schema.org (Minimal) 9.7 kB 8.8 kB 26.9 kB
Schema.org Imports 0.1 kB 0.1 kB 0.1 kB
Schema.org Vue Meta 0.4 kB 0.3 kB 0.8 kB

⚡ Performance (directional)

No significant change (within CI noise)

All benchmarks (14)
Benchmark PR Δ RME
SSR render (CPU) 0.417 ms ~ noise ±11.5%
SSR render (wall) 0.279 ms ~ noise ±6.1%
SSR allocated / render 273.2 KiB ~ noise
Schema.org cached render (CPU) 0.386 ms ~ noise ±5.4%
Schema.org cached render (wall) 0.253 ms ~ noise ±2.3%
Schema.org cached allocated / render 145.6 KiB ~ noise
Streaming wrapStream drain (CPU) 0.241 ms ~ noise ±4.8%
Streaming wrapStream drain (wall) 0.150 ms ~ noise ±4.8%
Streaming allocated / drain 161.2 KiB ~ noise
Streaming suspense chunk (CPU) 0.011 ms ~ noise ±4.3%
Streaming allocated / suspense chunk 4.6 KiB ~ noise
CSR DOM mutations / nav 38 ~ noise
CSR re-render (CPU) 0.805 ms ~ noise ±4.9%
CSR re-render (wall) 0.469 ms ~ noise ±2.2%

Baseline: main @ cc5127a · 2026-07-15 · gzipped is the headline size metric · perf is directional (shared-runner, gated)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/unhead/src/scripts/useScript.ts (1)

83-93: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Always return a disposer for duplicate keyed callbacks.

The SSR branch now honors the public disposer contract, but the duplicate-key branch at Line 92 still returns undefined. Calling that typed disposer will throw.

Proposed fix
     if (options?.key) {
       uniqueKey = `${key}:${options.key}`
       if (_uniqueCbs.has(uniqueKey)) {
-        return
+        return () => {}
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/unhead/src/scripts/useScript.ts` around lines 83 - 93, Update
_registerCb so the duplicate keyed-callback path returns a no-op disposer
function instead of undefined, matching the existing SSR branch and preserving
the typed disposer contract.
🧹 Nitpick comments (1)
packages/svelte/src/composables.ts (1)

66-72: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Prevent memory leaks by removing manually invoked disposers from the component's cleanup array.

Both the Svelte and Angular useScript bindings maintain a local sideEffects array to automatically clean up script event listeners when the component unmounts. However, when the user manually invokes the returned disposer (for example, inside a reactive watcher or effect cleanup), the listener is detached but its reference remains in sideEffects. If listeners are repeatedly registered and disposed over a long-lived component's lifetime, this array will grow unbounded, causing a memory leak.

Update the returned disposers to splice their associated cleanup function out of the sideEffects array.

  • packages/svelte/src/composables.ts#L66-L72: wrap the returned off function to splice it from sideEffects when called.
  • packages/angular/src/composables.ts#L75-L83: update the returned disposer inside _registerCb to splice off from sideEffects if it exists.
♻️ Proposed fixes

packages/svelte/src/composables.ts

  const bind = (base: (cb: any) => () => void) => (cb: any) => {
    const off = base(cb)
    sideEffects.push(off)
-   return off
+   return () => {
+     const idx = sideEffects.indexOf(off)
+     if (idx !== -1)
+       sideEffects.splice(idx, 1)
+     off()
+   }
  }

packages/angular/src/composables.ts
Note: Modify the returned disposer starting at line 90 in the _registerCb function context.

    return () => {
      if (disposed)
        return
      disposed = true
      const idx = mountCbs.indexOf(run)
      if (idx !== -1)
        mountCbs.splice(idx, 1)
+     if (off) {
+       const sideEffectIdx = sideEffects.indexOf(off)
+       if (sideEffectIdx !== -1)
+         sideEffects.splice(sideEffectIdx, 1)
+     }
      off?.()
    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/svelte/src/composables.ts` around lines 66 - 72, Update the disposer
wrapping in packages/svelte/src/composables.ts at lines 66-72 so the returned
function removes its associated off callback from sideEffects before invoking
it. Apply the same cleanup in packages/angular/src/composables.ts at lines 75-83
within _registerCb, splicing off from sideEffects when present; both sites must
prevent manually disposed listeners from remaining in the cleanup array.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/unhead/src/scripts/types.ts`:
- Around line 143-146: Handle nullish use() results as terminal failures instead
of leaving load promises pending. In packages/unhead/src/scripts/types.ts lines
65-67, define the resolved API type as NonNullable<Awaited<ReturnType<V>>>; in
packages/unhead/src/scripts/types.ts lines 143-146, remove null and undefined
from the use() contract or classify them as failure; and in
packages/unhead/src/scripts/useScript.ts lines 162-170, add a terminal branch
for nullish outcome.api while preserving existing success handling.

In `@packages/unhead/src/scripts/useScript.ts`:
- Around line 321-346: Update the trigger execution flow in useScript so the
catch path fully tears down lifecycle state when trigger(script.load) throws,
including aborting the trigger controller, removing lifecycle signal/hook state,
and cleaning up any injected entry started by use() or load(). Ensure this
cleanup occurs before rethrowing and before useScript() can leave partial
registration.
- Around line 359-364: Update the terminal error replay in the script loading
flow so late onError subscribers receive the captured loadError, matching the
existing early callback behavior. Locate the _registerCb replay path and pass
loadError when invoking registered error callbacks instead of calling them
without an argument.

---

Outside diff comments:
In `@packages/unhead/src/scripts/useScript.ts`:
- Around line 83-93: Update _registerCb so the duplicate keyed-callback path
returns a no-op disposer function instead of undefined, matching the existing
SSR branch and preserving the typed disposer contract.

---

Nitpick comments:
In `@packages/svelte/src/composables.ts`:
- Around line 66-72: Update the disposer wrapping in
packages/svelte/src/composables.ts at lines 66-72 so the returned function
removes its associated off callback from sideEffects before invoking it. Apply
the same cleanup in packages/angular/src/composables.ts at lines 75-83 within
_registerCb, splicing off from sideEffects when present; both sites must prevent
manually disposed listeners from remaining in the cleanup array.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 467321cd-07ca-49ba-b020-0bf53f615aaa

📥 Commits

Reviewing files that changed from the base of the PR and between cc5127a and 116e8c8.

📒 Files selected for processing (10)
  • docs/head/7.api/composables/4.use-script.md
  • packages/angular/src/composables.ts
  • packages/svelte/src/composables.ts
  • packages/unhead/src/scripts/types.ts
  • packages/unhead/src/scripts/useScript.ts
  • packages/unhead/test/unit/scripts/events.test.ts
  • packages/unhead/test/unit/scripts/proxy.test.ts
  • packages/unhead/test/unit/scripts/use.test.ts
  • packages/vue/src/scripts/index.ts
  • packages/vue/src/scripts/useScript.ts

Comment thread packages/unhead/src/scripts/types.ts
Comment thread packages/unhead/src/scripts/useScript.ts
Comment thread packages/unhead/src/scripts/useScript.ts
@harlan-zw

Copy link
Copy Markdown
Collaborator Author

Addressed the two non-inline review findings in f7cc932c as well:

  • duplicate keyed callbacks now return a safe no-op disposer, matching the public contract
  • manually invoked Svelte and Angular disposers remove their stored cleanup reference instead of retaining it until scope teardown

Focused lint, 35 lifecycle/framework tests, and workspace typecheck pass locally.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/svelte/src/composables.ts`:
- Around line 66-67: Update the Svelte composables’ bind helper to accept the
registration options and forward them to the base callback registration
function, preserving keyed deduplication. Apply the same change to the Angular
composables’ baseOnLoaded and baseOnError registration calls so their options
are not dropped.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ba563624-6bb0-4b36-83e9-9078b1650e2f

📥 Commits

Reviewing files that changed from the base of the PR and between 116e8c8 and f7cc932.

📒 Files selected for processing (5)
  • packages/angular/src/composables.ts
  • packages/svelte/src/composables.ts
  • packages/unhead/src/scripts/types.ts
  • packages/unhead/src/scripts/useScript.ts
  • packages/unhead/test/unit/scripts/events.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/unhead/src/scripts/types.ts
  • packages/angular/src/composables.ts
  • packages/unhead/test/unit/scripts/events.test.ts

Comment thread packages/svelte/src/composables.ts Outdated
* feat(scripts): add consumer lifecycle scopes

* fix(react): isolate script callback registrations

* fix(scripts): harden scoped lifecycle facades

* fix(scripts): guard callback and trigger results
@harlan-zw harlan-zw changed the title feat(scripts): support abortable async API readiness feat(scripts): add async readiness and consumer scopes Jul 16, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/head/7.api/composables/4.use-script.md`:
- Around line 186-190: Update the useScript example so onLoadedEffect() is
called on a ScriptScope created via the controller’s createScope() method,
rather than directly on the generic script controller. Keep the existing
callback behavior unchanged and ensure the example matches the documented return
types.

In `@packages/react/src/composables.ts`:
- Around line 217-221: Update the handler and disposer logic in the composable
around the callback record so callback execution no longer marks the record
disposed before cleanup runs. Track callback invocation separately from
disposal, ensure record.off and record removal still execute when the disposer
is called afterward, and preserve keyed callback re-registration within the same
mounted component; add coverage for invoke, dispose, then register the same key
again.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e109425b-6b09-4b46-a190-7d60c29e87bc

📥 Commits

Reviewing files that changed from the base of the PR and between f1eda4d and bd76376.

📒 Files selected for processing (17)
  • docs/head/7.api/composables/4.use-script.md
  • packages/angular/src/composables.ts
  • packages/react/src/composables.ts
  • packages/react/test/useScript.test.tsx
  • packages/solid-js/src/composables.ts
  • packages/svelte/src/composables.ts
  • packages/unhead/src/composables.ts
  • packages/unhead/src/index.ts
  • packages/unhead/src/scripts/index.ts
  • packages/unhead/src/scripts/scope.ts
  • packages/unhead/src/scripts/types.ts
  • packages/unhead/src/scripts/useScript.ts
  • packages/unhead/src/scripts/waitFor.ts
  • packages/unhead/test/unit/scripts/events.test.ts
  • packages/unhead/test/unit/scripts/scope.test.ts
  • packages/unhead/test/unit/scripts/use.test.ts
  • packages/vue/src/scripts/useScript.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/unhead/test/unit/scripts/use.test.ts
  • packages/unhead/test/unit/scripts/events.test.ts
  • packages/unhead/src/scripts/useScript.ts

Comment thread docs/head/7.api/composables/4.use-script.md Outdated
Comment thread packages/react/src/composables.ts
@harlan-zw

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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