| name | locker-lwr-compat |
| description | Catch patterns that work in LWR (Lightning Web Runtime) but break, silently misbehave, or get sandbox-rejected in Lightning Locker. Use when editing LWC code, when the user mentions "Locker", "LWR", "Experience Cloud", "Lightning Web Security", "LWS", "managed package", "AppExchange", "two worlds", or runs `/locker-check`. Especially relevant for AppExchange-targeted code where the package may install into orgs running Locker even if your dev org runs LWS/LWR. The runtime mismatch is silent — code passes locally and breaks for the customer — so flag aggressively. |
Locker / LWR Compatibility Skill
Lightning runs LWC under one of three runtime regimes depending on org and context. Code that works in one can fail in another with no static signal. AppExchange packages especially — your dev org may run LWS, but your subscriber org may still be on Locker for some namespace, surface, or feature combination. The defensive default is: assume Locker until proven otherwise, and run /locker-check before any release.
The three regimes (in compatibility order, most permissive first)
- LWR / Lightning Web Runtime — modern runtime used in LWR Experience Cloud sites and some App Builder contexts. Closest to standard browser semantics. Most permissive.
- LWS / Lightning Web Security — Salesforce's replacement for Locker. JavaScript-level isolation rather than proxy-wrapped DOM. More permissive than Locker, less than LWR.
- Lightning Locker — the legacy strict sandbox. Proxy-wrapped DOM, intercepted globals, restricted APIs. Most restrictive.
For AppExchange, you must work in all three. For an admin-only internal LWC, you may only need to care about whichever is active in the surface you target.
Patterns to flag (with severity, fix, and why)
Locker-incompatible — BLOCKER
These will break in Locker. They may pass eslint and Jest. They will fail in subscriber orgs that haven't migrated.
| Pattern | Why it breaks | Fix |
|---|
window.document direct manipulation | Locker proxies window and document; many descendant accesses are stripped. | Use the this.template query-selector path (this.template.querySelector(...)). |
globalThis / window shared state across components | Locker scopes window per namespace. Two components in different namespaces don't see the same window.foo. | LMS for cross-namespace signals. Module-level state for same-namespace. |
document.createElement('script') | Blocked outright; also a Checkmarx CX-6 BLOCKER. | Static resource imports, loadScript from lightning/platformResourceLoader. |
XMLHttpRequest direct, or fetch to non-allowlisted origin | Locker requires CSP Trusted Sites and Remote Site Settings; XMLHttpRequest is heavily filtered. | Use Apex callouts via Named Credentials. If you must fetch, ensure Trusted Sites entry exists. |
navigator.clipboard write | Locker blocks; LWS allows in user-gesture contexts. | Feature-detect; fall back to a hidden textarea + document.execCommand('copy') (deprecated but still works in Locker). |
CustomEvent with composed: true crossing the shadow boundary into <aura:component> host | Locker blocks composed events at namespace boundaries. | bubbles: true only inside one namespace; LMS for cross-namespace. |
eval, new Function(...) | Blocked in Locker; also Checkmarx CX-6 BLOCKER. | Don't. |
Direct DOM property mutation through this.refs, e.g. this.refs.input.value = '...' then expecting change | Works in LWR, intercepted in Locker — value may not propagate to the underlying input. | Use a property @track and re-render via the template binding. |
Element.shadowRoot.innerHTML reads | Locker proxies shadow roots; reading innerHTML returns a stripped or wrapped value. | Don't introspect shadow roots from outside the component. |
Locker-suspect — HIGH
These often work in Locker but with subtle behavior differences. Test on a Locker org explicitly.
| Pattern | Locker behavior | Fix |
|---|
Object.defineProperty on imported records | Frozen by Locker proxies; throws silently in some contexts. | Spread into a new object before mutating. |
Date.now() arithmetic across the Locker boundary | OK, but Date instances passed to wire methods may get wrapped. | Pass primitives (numbers, ISO strings) across boundaries. |
requestAnimationFrame for layout reads | Works, but Locker's intercept layer can reorder reads vs writes. | Use lightning/platformResourceLoader patterns or window.setTimeout(..., 0). |
IntersectionObserver on host elements | Works in LWR, sometimes returns empty entries for cross-namespace targets in Locker. | Same-namespace targets only; otherwise polyfill via scroll events. |
Imported third-party JS via loadScript that uses globalThis | Often crashes at load when the lib expects unfiltered window. | Vendor only Locker-tested libraries. Maintain an allow-list. |
LWS/LWR-only — INFORMATIONAL
These rely on LWS/LWR semantics. Fine if your surface is exclusively LWS/LWR. Don't ship in an AppExchange package without runtime detection.
| Pattern | LWS/LWR behavior | Note |
|---|
?? nullish coalescing chained with optional access on imported records | Works directly; in Locker the proxy may not pass through nullish for some adapters. | Test before relying on it. |
WeakRef / FinalizationRegistry | Allowed in LWS/LWR. Not in Locker. | Detect runtime before using. |
Native structuredClone() | Allowed; Locker historically had a polyfilled subset. | Verify on Spring/Summer release matrix. |
How to detect runtime at runtime (when you genuinely need both branches)
Avoid this if you can. If you must:
const isLWR = !window.LightningTestingService && !!window.location.host.match(/\.site\.com|\.my\.salesforce-experience\.com/);
const isLockerLike = typeof window.SecureWindow !== 'undefined' || (window === Object.getPrototypeOf(window).constructor.prototype);
The cleaner approach is to ship code that works under Locker and let it run unchanged under LWS/LWR. Locker is the lowest common denominator; the others tolerate Locker-safe patterns.
What /locker-check does (this skill is invoked there too)
When the user runs /locker-check:
- Grep the LWC source tree for every BLOCKER and HIGH pattern in the table above.
- For each match, classify by severity and produce file:line and a one-line fix.
- Cross-reference against
force-app/**/*/lwc.config.json (if any) and loadScript calls — flag third-party libs whose Locker compatibility hasn't been documented.
- Emit a verdict: LOCKER-SAFE / NEEDS REVIEW / BLOCKED.
- Save a copy of the report to
~/Desktop/locker-check-YYYY-MM-DD.md so the user can attach it to release notes.
What this skill is NOT
- Not a security skill — security falls to
appexchange-review-patterns and /quality-warden.
- Not a Jest patcher — Jest doesn't simulate Locker. You still need a Locker-enabled scratch org for real validation.
- Not a static analyzer — it's pattern-based heuristics that will miss novel violations. Treat as a tripwire, not a proof.
When to escalate
If the component imports a third-party JS library, uses lwc:dom="manual", or relies on browser APIs added after ~2022, always suggest a manual smoke test on a Locker-enabled org. The patterns above are necessary-not-sufficient.