fix(build): keep UMD exports overridable while preserving live bindings#8839
Conversation
#8837 fixed the live binding of mutable UMD exports (notably `pc.app`) by copying the esbuild namespace getters onto `exports` with `Object.defineProperties(exports, Object.getOwnPropertyDescriptors(pc))`. That made every export getter-only, which silently broke consumers that reassign members of the global `pc` namespace — e.g. the Editor's classic-script worker overrides `pc.createScript` / `pc.registerScript`. With the assignment failing, the engine's real `createScript` ran in the worker and threw `Cannot read properties of undefined (reading 'scripts')` from `AppBase.getApplication()` (no application exists in the worker). Wrap each export getter in an accessor that delegates reads to the live binding (so `pc.app` still updates) but also has a setter, so assignments like `pc.createScript = ...` redefine the property as a writable value and take effect again. Restores the writable-export behaviour of the pre-2.19 (Rollup) UMD build without regressing the live-binding fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Reproduced the exact reported failure (parsing
Matches the reported stack trace exactly: |
There was a problem hiding this comment.
Pull request overview
This PR updates the UMD build footer bridge logic so global pc exports retain live-binding behavior (e.g. pc.app) while also remaining overridable by consumers (e.g. pc.createScript = stub), addressing a regression introduced by the descriptor-copy approach from #8837.
Changes:
- Replace the UMD
Object.definePropertiesbridge with per-export accessor wrappers that add a setter to allow overrides. - Preserve live bindings by delegating reads to the original export getters while allowing writes to “lock in” an overridden value.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // - keeps the property writable, so consumers can still override members of `pc` (e.g. the | ||
| // Editor's classic-script worker reassigns `pc.createScript`). A bare descriptor copy makes | ||
| // the export getter-only and breaks such overrides | ||
| // (see https://github.com/playcanvas/engine/issues/8836). |
| return /* js */ `for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(pc))) { | ||
| \tObject.defineProperty(exports, key, descriptor.get ? { | ||
| \t\tget: descriptor.get, | ||
| \t\tset: (value) => Object.defineProperty(exports, key, { value, writable: true, enumerable: true, configurable: true }), |
There was a problem hiding this comment.
Fixed in 93f41a2 — the setter now uses enumerable: descriptor.enumerable for parity with the original export. (In practice all accessor-path exports are enumerable; only the non-enumerable __esModule marker takes the plain-descriptor branch, so there's no behavioural change — but the parity is correct.)
- Point the override-regression reference to #8837 (the PR that made exports getter-only), not #8836 (the pc.app live-binding issue). - Preserve the original export's enumerability in the override setter instead of hardcoding `enumerable: true`, keeping attribute parity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gs (#8839) * fix(build): keep UMD exports overridable while preserving live bindings #8837 fixed the live binding of mutable UMD exports (notably `pc.app`) by copying the esbuild namespace getters onto `exports` with `Object.defineProperties(exports, Object.getOwnPropertyDescriptors(pc))`. That made every export getter-only, which silently broke consumers that reassign members of the global `pc` namespace — e.g. the Editor's classic-script worker overrides `pc.createScript` / `pc.registerScript`. With the assignment failing, the engine's real `createScript` ran in the worker and threw `Cannot read properties of undefined (reading 'scripts')` from `AppBase.getApplication()` (no application exists in the worker). Wrap each export getter in an accessor that delegates reads to the live binding (so `pc.app` still updates) but also has a setter, so assignments like `pc.createScript = ...` redefine the property as a writable value and take effect again. Restores the writable-export behaviour of the pre-2.19 (Rollup) UMD build without regressing the live-binding fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs/fix(build): address PR review on UMD footer - Point the override-regression reference to #8837 (the PR that made exports getter-only), not #8836 (the pc.app live-binding issue). - Preserve the original export's enumerability in the override setter instead of hardcoding `enumerable: true`, keeping attribute parity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Martin Valigursky <mvaligursky@snapchat.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… gate The mocha suite runs against src/ only, and CI just runs publint on the bundles, so build-output regressions ship undetected (e.g. #8836 pc.app frozen null, #8839 getter-only exports breaking pc.createScript overrides). Adds: - test/bundles/: a post-build mocha suite (npm run test:build) that loads the built bundles (rel/dbg/prf/min x UMD/ESM) and asserts they load, boot an Application, export-parity with src/index.js, pc.app live binding, exports overridable, and that every UMD export is a live+overridable accessor. - api-extractor: a type-surface gate. npm run api:check verifies the built .d.ts against the committed etc/playcanvas.api.md; npm run api:update regenerates it. Wired into the CI typescript-declarations job. - CI: run test:build in the build job; api:check in the declarations job. Closes #8838
… gate The mocha suite runs against src/ and CI only publints the bundles, so build-output regressions ship undetected (e.g. #8836 pc.app null in UMD, #8839 getter-only exports breaking pc.createScript overrides). - test/bundles/: post-build mocha suite (npm run test:build) loading the built bundles (rel/dbg/prf/min x UMD/ESM) - each loads (UMD global + CJS, ESM import) and boots an Application; runtime exports match src/index.js; pc.app live binding (#8836/#8837); UMD exports overridable (#8839); plus a blanket invariant that every UMD export is a live + overridable accessor. - api-extractor: public API surface gate. npm run api:check verifies the built .d.ts against etc/playcanvas.api.md (compact colored diff on failure); npm run api:update regenerates it. Scratch report under build/.api-extractor/. New dev dependency: @microsoft/api-extractor. - CI: run test:build in the build job; api:check in the typescript-declarations job. Closes #8838
The mocha suite runs against src/ and CI only publints the bundles, so build-output regressions ship undetected (e.g. #8836 pc.app null in UMD, #8839 getter-only exports breaking pc.createScript overrides). test/bundles/: a post-build mocha suite (npm run test:build) that loads the built bundles (rel/dbg/prf/min x UMD/ESM) - each loads (UMD as a browser global and via CommonJS, ESM via import) and boots an Application; runtime exports match src/index.js; pc.app live binding (#8836/#8837); UMD exports overridable (#8839); plus a blanket invariant that every UMD export is a live + overridable accessor. Runs in the build CI job. Closes #8838
The mocha suite runs against src/ and CI only publints the bundles, so build-output regressions ship undetected (e.g. #8836 pc.app null in UMD, #8839 getter-only exports breaking pc.createScript overrides). test/bundles/: a post-build mocha suite (npm run test:build) that loads the built bundles (rel/dbg/prf/min x UMD/ESM) - each loads (UMD as a browser global and via CommonJS, ESM via import) and boots an Application; runtime exports match src/index.js; pc.app live binding (#8836/#8837); UMD exports overridable (#8839); plus a blanket invariant that every UMD export is a live + overridable accessor. Runs in the build CI job. Closes #8838
The mocha suite runs against src/ and CI only publints the bundles, so build-output regressions ship undetected (e.g. #8836 pc.app null in UMD, #8839 getter-only exports breaking pc.createScript overrides). test/bundles/: a post-build mocha suite (npm run test:build) that loads the built bundles (rel/dbg/prf/min x UMD/ESM) - each loads (UMD as a browser global and via CommonJS, ESM via import) and boots an Application; runtime exports match src/index.js; pc.app live binding (#8836/#8837); UMD exports overridable (#8839); plus a blanket invariant that every UMD export is a live + overridable accessor. Runs in the build CI job. Closes #8838
The mocha suite runs against src/ and CI only publints the bundles, so build-output regressions ship undetected (e.g. #8836 pc.app null in UMD, #8839 getter-only exports breaking pc.createScript overrides). test/bundles/: a post-build mocha suite (npm run test:build) that loads the built bundles (rel/dbg/prf/min x UMD/ESM) - each loads (UMD as a browser global and via CommonJS, ESM via import) and boots an Application; runtime exports match src/index.js; pc.app live binding (#8836/#8837); UMD exports overridable (#8839); plus a blanket invariant that every UMD export is a live + overridable accessor. Runs in the build CI job. Closes #8838
Summary
Follow-up to #8837. That PR fixed the live binding of mutable UMD exports (notably
pc.app, see #8836) but, as a side effect, made every UMD export getter-only. This silently broke any consumer that reassigns a member of the globalpcnamespace.The most visible casualty is the Editor's classic-script worker (
classic-script.worker.ts), which parses scripts by overridingpc.createScript/pc.registerScriptwith stubs:After #8837 those assignments hit a getter-only property and don't take effect, so the engine's real
createScript→registerScriptruns in the worker and throws:AppBase.getApplication()isundefinedin the worker (no application is ever created there), so.scriptsthrows. Reported on the forum (classic script parsing broken in 2.19.3).Fix
Wrap each export getter in an accessor that has both a getter and a setter:
pc.appstill updates (keeps pc.app - is not available. Breaking projects. #8836 fixed).pc.createScript = …and similar overrides take effect again.This restores the writable-export semantics of the pre-2.19 (Rollup) UMD build without regressing the live-binding fix.
Verification
Built the UMD bundle, loaded it as a browser global in a
vmsandbox:Object.keys(pc).lengthpc.appbefore app / afternew pc.AppBase()null→=== app(live) ✅pc.createScript = stubtakes effectpc.registerScript = stubtakes effectpc.appstill live after overriding other memberspc.script.createLoadingScreen = …Before this change (current
main/ 2.19.3) the override assignments throw "Cannot set property createScript … which has only a getter" and silently leave the engine implementation in place — reproducing the worker crash.Note
There's also a latent footgun worth a separate look:
registerScripthard-requiresAppBase.getApplication()when noappis passed, which is fragile in app-less contexts (workers, headless parsing). Out of scope here.🤖 Generated with Claude Code