@@ -49,8 +49,11 @@ export type ScriptProps = {
4949 [ key : string ] : unknown ;
5050} ;
5151
52- // Track scripts that have already been loaded to avoid duplicates
52+ // Track scripts that have already been loaded, plus remote scripts currently
53+ // loading, to avoid duplicate DOM insertion when same-src components mount
54+ // before the first load event fires.
5355const loadedScripts = new Set < string > ( ) ;
56+ const loadingScripts = new Map < string , Promise < Event > > ( ) ;
5457
5558function getClientAutoNonce ( ) : string | undefined {
5659 if ( typeof document === "undefined" ) return undefined ;
@@ -96,51 +99,118 @@ function buildBeforeInteractiveScriptProps(options: {
9699 return scriptProps ;
97100}
98101
99- /**
100- * Load a script imperatively (outside of React).
101- */
102- export function handleClientScriptLoad ( props : ScriptProps ) : void {
102+ function setScriptAttributes ( el : HTMLScriptElement , rest : Record < string , unknown > ) : void {
103+ for ( const [ attr , value ] of Object . entries ( rest ) ) {
104+ if ( attr === "dangerouslySetInnerHTML" ) continue ;
105+ if ( attr === "className" ) {
106+ el . setAttribute ( "class" , String ( value ) ) ;
107+ } else if ( typeof value === "string" ) {
108+ el . setAttribute ( attr , value ) ;
109+ } else if ( typeof value === "boolean" && value ) {
110+ el . setAttribute ( attr , "" ) ;
111+ }
112+ }
113+ }
114+
115+ function loadClientScript (
116+ props : ScriptProps ,
117+ options : {
118+ resolvedNonce ?: string ;
119+ fireReadyWhenAlreadyLoaded : boolean ;
120+ } ,
121+ ) : void {
103122 const {
104123 src,
105124 id,
106125 onLoad,
126+ onReady,
107127 onError,
108- strategy : _strategy ,
109- onReady : _onReady ,
128+ strategy = "afterInteractive" ,
110129 children,
130+ dangerouslySetInnerHTML,
111131 ...rest
112132 } = props ;
113133 if ( typeof window === "undefined" ) return ;
114134
115135 const key = id ?? src ?? "" ;
116- if ( key && loadedScripts . has ( key ) ) return ;
136+ if ( key && loadedScripts . has ( key ) ) {
137+ if ( options . fireReadyWhenAlreadyLoaded ) {
138+ onReady ?.( ) ;
139+ }
140+ return ;
141+ }
142+
143+ if ( src ) {
144+ const existingLoad = loadingScripts . get ( src ) ;
145+ if ( existingLoad ) {
146+ void existingLoad . then (
147+ ( event ) => {
148+ if ( key ) loadedScripts . add ( key ) ;
149+ onLoad ?.( event ) ;
150+ onReady ?.( ) ;
151+ } ,
152+ ( event ) => onError ?.( event ) ,
153+ ) ;
154+ return ;
155+ }
156+ }
117157
118158 const el = document . createElement ( "script" ) ;
119159 if ( src ) el . src = src ;
120160 if ( id ) el . id = id ;
121- const resolvedNonce = resolveScriptNonce ( rest . nonce ) ;
122161
123- for ( const [ attr , value ] of Object . entries ( rest ) ) {
124- if ( attr === "dangerouslySetInnerHTML" || attr === "className" ) continue ;
125- if ( typeof value === "string" ) {
126- el . setAttribute ( attr , value ) ;
127- } else if ( typeof value === "boolean" && value ) {
128- el . setAttribute ( attr , "" ) ;
129- }
162+ setScriptAttributes ( el , rest ) ;
163+ if ( options . resolvedNonce && ! el . getAttribute ( "nonce" ) ) {
164+ el . setAttribute ( "nonce" , options . resolvedNonce ) ;
130165 }
131- if ( resolvedNonce && ! el . getAttribute ( "nonce" ) ) {
132- el . setAttribute ( "nonce" , resolvedNonce ) ;
166+
167+ if ( strategy === "worker" ) {
168+ el . setAttribute ( "type" , "text/partytown" ) ;
133169 }
134170
135- if ( children && typeof children === "string" ) {
171+ const markLoaded = ( ) => {
172+ if ( key ) loadedScripts . add ( key ) ;
173+ onReady ?.( ) ;
174+ } ;
175+
176+ if ( dangerouslySetInnerHTML ?. __html ) {
177+ // Intentional: mirrors the Next.js <Script> API where dangerouslySetInnerHTML
178+ // is developer-supplied inline script content (not user input). The prop name
179+ // itself signals developer awareness of the XSS risk, consistent with React's
180+ // design. User-supplied data must never flow into this prop.
181+ el . innerHTML = dangerouslySetInnerHTML . __html ;
182+ markLoaded ( ) ;
183+ } else if ( children && typeof children === "string" ) {
136184 el . textContent = children ;
185+ markLoaded ( ) ;
186+ } else if ( src ) {
187+ const loadPromise = new Promise < Event > ( ( resolve , reject ) => {
188+ el . addEventListener ( "load" , ( event ) => {
189+ resolve ( event ) ;
190+ if ( key ) loadedScripts . add ( key ) ;
191+ onLoad ?.( event ) ;
192+ onReady ?.( ) ;
193+ } ) ;
194+ el . addEventListener ( "error" , ( event ) => {
195+ reject ( event ) ;
196+ onError ?.( event ) ;
197+ } ) ;
198+ } ) ;
199+ loadPromise . catch ( ( ) => undefined ) . finally ( ( ) => loadingScripts . delete ( src ) ) ;
200+ loadingScripts . set ( src , loadPromise ) ;
137201 }
138202
139- if ( onLoad ) el . addEventListener ( "load" , onLoad ) ;
140- if ( onError ) el . addEventListener ( "error" , onError ) ;
141-
142203 document . body . appendChild ( el ) ;
143- if ( key ) loadedScripts . add ( key ) ;
204+ }
205+
206+ /**
207+ * Load a script imperatively (outside of React).
208+ */
209+ export function handleClientScriptLoad ( props : ScriptProps ) : void {
210+ loadClientScript ( props , {
211+ resolvedNonce : resolveScriptNonce ( props . nonce ) ,
212+ fireReadyWhenAlreadyLoaded : false ,
213+ } ) ;
144214}
145215
146216/**
@@ -192,48 +262,20 @@ function Script(props: ScriptProps): React.ReactElement | null {
192262 return ;
193263 }
194264
195- const el = document . createElement ( "script" ) ;
196- if ( src ) el . src = src ;
197- if ( id ) el . id = id ;
198-
199- for ( const [ attr , value ] of Object . entries ( rest ) ) {
200- if ( attr === "className" ) {
201- el . setAttribute ( "class" , String ( value ) ) ;
202- } else if ( typeof value === "string" ) {
203- el . setAttribute ( attr , value ) ;
204- } else if ( typeof value === "boolean" && value ) {
205- el . setAttribute ( attr , "" ) ;
206- }
207- }
208- if ( resolvedNonce && ! el . getAttribute ( "nonce" ) ) {
209- el . setAttribute ( "nonce" , resolvedNonce ) ;
210- }
211-
212- if ( strategy === "worker" ) {
213- el . setAttribute ( "type" , "text/partytown" ) ;
214- }
215-
216- if ( dangerouslySetInnerHTML ?. __html ) {
217- // Intentional: mirrors the Next.js <Script> API where dangerouslySetInnerHTML
218- // is developer-supplied inline script content (not user input). The prop name
219- // itself signals developer awareness of the XSS risk, consistent with React's
220- // design. User-supplied data must never flow into this prop.
221- el . innerHTML = dangerouslySetInnerHTML . __html as string ;
222- } else if ( children && typeof children === "string" ) {
223- el . textContent = children ;
224- }
225-
226- el . addEventListener ( "load" , ( e ) => {
227- if ( key ) loadedScripts . add ( key ) ;
228- onLoad ?.( e ) ;
229- onReady ?.( ) ;
230- } ) ;
231-
232- if ( onError ) {
233- el . addEventListener ( "error" , onError ) ;
234- }
235-
236- document . body . appendChild ( el ) ;
265+ loadClientScript (
266+ {
267+ src,
268+ id,
269+ strategy,
270+ onLoad,
271+ onReady,
272+ onError,
273+ children,
274+ dangerouslySetInnerHTML,
275+ ...rest ,
276+ } ,
277+ { resolvedNonce, fireReadyWhenAlreadyLoaded : true } ,
278+ ) ;
237279 } ;
238280
239281 if ( strategy === "lazyOnload" ) {
0 commit comments