Skip to content

feat!: make external scripts configurable per-app#227

Open
arbrandes wants to merge 1 commit intoopenedx:mainfrom
arbrandes:external-scripts
Open

feat!: make external scripts configurable per-app#227
arbrandes wants to merge 1 commit intoopenedx:mainfrom
arbrandes:external-scripts

Conversation

@arbrandes
Copy link
Copy Markdown
Contributor

@arbrandes arbrandes commented Apr 15, 2026

Description

External scripts were previously hardcoded in initialize() with GoogleAnalyticsLoader as the default. This made them non-configurable and forced every site to bundle Google Analytics whether it was used or not.

This PR makes external scripts a per-app concern via a new externalScripts field on the App interface, following the same accumulation pattern as routes and slots. Each app's scripts receive that app's merged config (commonAppConfig + per-app config) via getAppConfig().

GoogleAnalyticsLoader moves to a new top-level contrib/ directory as a pre-built app that operators opt into via site.config. A wildcard exports map entry ("./contrib/*") makes future contrib apps automatically importable without changes to package.json.

See ADR 0014 for full rationale.

Test/Demonstration

Run the following of template-site against this PR (by the usual expedient of checking out this version of frontend-base into packages/frontend-base):

openedx/frontend-template-site#13

If you look in the Network tab, you should see a request go out to Google's servers. (In particular, to https://www.googletagmanager.com/gtag/js?id=G-TEST123.)

LLM usage notice

Built with assistance from Claude.

@arbrandes arbrandes linked an issue Apr 15, 2026 that may be closed by this pull request
External scripts were previously hardcoded in initialize() with
GoogleAnalyticsLoader as the default. This made them non-configurable
and forced every site to bundle Google Analytics whether it was used
or not.

External scripts are now a per-app concern via the new externalScripts
field on the App interface, following the same accumulation pattern as
routes and slots. Each app's scripts receive that app's merged config
(commonAppConfig + per-app config) via getAppConfig().

GoogleAnalyticsLoader moves to a new top-level contrib/ directory as a
pre-built app that operators opt into via site.config. A wildcard
exports map entry makes future contrib apps importable without changes
to package.json.

See ADR 0014 for full rationale.

BREAKING CHANGE: GoogleAnalyticsLoader is no longer loaded by default.
Operators must add the contrib app to their site.config to enable it.

Co-Authored-By: Claude <noreply@anthropic.com>
without expanding the core runtime or the main export surface.

External script configuration (e.g. ``GOOGLE_ANALYTICS_4_ID``) lives in
``commonAppConfig`` or per-app ``config``, not in the site config type.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lives in commonAppConfig or per-app config, not in the site config type.

Isn't commonAppConfig in the site config?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yup! When you getAppConfig() the common config gets merged in.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Oh, I see. This is meant to say not in the _top-level_ site config.

Nice catch!

Copy link
Copy Markdown
Member

@MaxFrank13 MaxFrank13 left a comment

Choose a reason for hiding this comment

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

Seems clean! A long-awaited feature for sure. Just had one question

Copy link
Copy Markdown
Contributor

@brian-smith-tcril brian-smith-tcril left a comment

Choose a reason for hiding this comment

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

Overall I think this makes a lot of sense. I do think there are a couple cases we should add documentation for though:

  • Writing a little app to load a script directly in site config
    • Likely a common use case, something we can support in Tutor MFE
  • Writing a loader and adding it to specific apps
    • The example in this PR is "the loader lives in an app, the app is always in the site" - having a "I just want to add this script on learner-dash" example would be nice

Another thing worth noting is that it seems like there are 2 ways to make sure a loader is always loaded: making an app for it and adding it to commonAppConfig. It'd be good to document why the app strategy is preferred over the commonAppConfig strategy (my understanding is that it's because if you put it in commonAppConfig you need to be more vigilant about having "don't run twice" guards in the loader itself)

Comment thread types.ts
loadScript(): void,
}

export type ExternalScriptLoaderClass = new (data: { config: AppConfig }) => ExternalScriptLoader;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I know we discussed switching to using config directly instead of data { config: } in the frontend-platform PR. That one landed with the data object.

At this point I think I'm fine either way, but it'd be good to be consistent.

Comment thread jest.config.js
@@ -1,5 +1,6 @@
module.exports = {
projects: [
'contrib',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If this can live in a different repo instead of here that'd be awesome! Will follow up after the standup discussion.

Comment on lines +83 to +85
Operators must explicitly opt in to Google Analytics by adding the contrib app
to their ``apps`` array in ``site.config``. Sites that do not use Google
Analytics no longer load the script by default.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We might want to include the GA app in the template site used by Tutor MFE until we fully DEPR GA.

Comment on lines +102 to +104
commonAppConfig: {
GOOGLE_ANALYTICS_4_ID: 'G-XXXXXXXXXX',
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With the loader living it its own app this wouldn't need to be in commonAppConfig would it? It could just be in the GA app config?

Comment thread runtime/scripts/index.ts
apps.forEach(app => {
const config = getAppConfig(app.appId) ?? {};
(app.externalScripts ?? []).forEach(ExternalScript => {
const script = new ExternalScript({ config });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When discussing the frontend-platform PR the topic of potentially not having loadExternalScripts create new instances came up.

I'm wondering if we need the loaders to be classes at all, or if they can just be functions that take in config as a param.

I'm not opposed to sticking to classes (it seems to have been working for the GA loader for a long time), but I'd like to think through ways to possibly simplify this.

@arbrandes
Copy link
Copy Markdown
Contributor Author

We've decided to drop Google Analytics from here entirely. It's going to be a Tutor plugin instead.

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.

Add support for loading external scripts

3 participants