Skip to content

Commit

Permalink
perf(engine-core): reduce fragment cache objects (#4431)
Browse files Browse the repository at this point in the history
  • Loading branch information
nolanlawson authored Aug 6, 2024
1 parent c7124c8 commit fd3c9e6
Show file tree
Hide file tree
Showing 13 changed files with 219 additions and 40 deletions.
54 changes: 54 additions & 0 deletions packages/@lwc/engine-core/src/framework/fragment-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2024, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
import { ArrayFrom } from '@lwc/shared';

export const enum FragmentCacheKey {
HAS_SCOPED_STYLE = 1,
SHADOW_MODE_SYNTHETIC = 2,
}

// HAS_SCOPED_STYLE | SHADOW_MODE_SYNTHETIC = 3
const MAX_CACHE_KEY = 3;

// Mapping of cacheKeys to `string[]` (assumed to come from a tagged template literal) to an Element.
// Note that every unique tagged template literal will have a unique `string[]`. So by using `string[]`
// as the WeakMap key, we effectively associate each Element with a unique tagged template literal.
// See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates
// Also note that this array only needs to be large enough to account for the maximum possible cache key
const fragmentCache: WeakMap<string[], Element>[] = ArrayFrom(
{ length: MAX_CACHE_KEY + 1 },
() => new WeakMap()
);

// Only used in LWC's Karma tests
if (process.env.NODE_ENV === 'test-karma-lwc') {
(window as any).__lwcResetFragmentCache = () => {
for (let i = 0; i < fragmentCache.length; i++) {
fragmentCache[i] = new WeakMap();
}
};
}

function checkIsBrowser() {
// The fragment cache only serves prevent calling innerHTML multiple times which doesn't happen on the server.
/* istanbul ignore next */
if (!process.env.IS_BROWSER) {
throw new Error(
'The fragment cache is intended to only be used in @lwc/engine-dom, not @lwc/engine-server'
);
}
}

export function getFromFragmentCache(cacheKey: number, strings: string[]) {
checkIsBrowser();
return fragmentCache[cacheKey].get(strings);
}

export function setInFragmentCache(cacheKey: number, strings: string[], element: Element) {
checkIsBrowser();
fragmentCache[cacheKey].set(strings, element);
}
50 changes: 13 additions & 37 deletions packages/@lwc/engine-core/src/framework/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
isUndefined,
KEY__SCOPED_CSS,
keys,
noop,
StringCharAt,
STATIC_PART_TOKEN_ID,
toString,
Expand Down Expand Up @@ -47,6 +46,7 @@ import { getTemplateOrSwappedTemplate, setActiveVM } from './hot-swaps';
import { MutableVNodes, VNodes, VStaticPart, VStaticPartElement, VStaticPartText } from './vnodes';
import { RendererAPI } from './renderer';
import { getMapFromClassName } from './modules/computed-class-attr';
import { FragmentCacheKey, getFromFragmentCache, setInFragmentCache } from './fragment-cache';

export interface Template {
(api: RenderAPI, cmp: object, slotSet: SlotSet, cache: TemplateCache): VNodes;
Expand Down Expand Up @@ -238,40 +238,11 @@ function serializeClassAttribute(part: VStaticPartElement, classToken: string) {
return computedClassName.length ? ` class="${htmlEscape(computedClassName, true)}"` : '';
}

const enum FragmentCache {
HAS_SCOPED_STYLE = 1,
SHADOW_MODE_SYNTHETIC = 2,
}

// This should be a no-op outside of LWC's Karma tests, where it's not needed
let registerFragmentCache: (fragmentCache: any) => void = noop;

// Only used in LWC's Karma tests
if (process.env.NODE_ENV === 'test-karma-lwc') {
// Keep track of fragmentCaches, so we can clear them in LWC's Karma tests
const fragmentCaches: any[] = [];
registerFragmentCache = (fragmentCache: any) => {
fragmentCaches.push(fragmentCache);
};

(window as any).__lwcResetFragmentCaches = () => {
for (const fragmentCache of fragmentCaches) {
for (const key of keys(fragmentCache)) {
delete fragmentCache[key];
}
}
};
}

function buildParseFragmentFn(
createFragmentFn: (html: string, renderer: RendererAPI) => Element
): (strings: string[], ...keys: (string | number)[]) => () => Element {
return (strings: string[], ...keys: (string | number)[]) => {
const cache = create(null);

registerFragmentCache(cache);

return function (parts?: VStaticPart[]): Element {
return function parseFragment(strings: string[], ...keys: (string | number)[]) {
return function applyFragmentParts(parts?: VStaticPart[]): Element {
const {
context: { hasScopedStyles, stylesheetToken, legacyStylesheetToken },
shadowMode,
Expand All @@ -284,16 +255,16 @@ function buildParseFragmentFn(

let cacheKey = 0;
if (hasStyleToken && hasScopedStyles) {
cacheKey |= FragmentCache.HAS_SCOPED_STYLE;
cacheKey |= FragmentCacheKey.HAS_SCOPED_STYLE;
}
if (hasStyleToken && isSyntheticShadow) {
cacheKey |= FragmentCache.SHADOW_MODE_SYNTHETIC;
cacheKey |= FragmentCacheKey.SHADOW_MODE_SYNTHETIC;
}

// Cache is only here to prevent calling innerHTML multiple times which doesn't happen on the server.
if (process.env.IS_BROWSER) {
// Disable this on the server to prevent cache poisoning when expressions are used.
const cached = cache[cacheKey];
const cached = getFromFragmentCache(cacheKey, strings);
if (!isUndefined(cached)) {
return cached;
}
Expand Down Expand Up @@ -343,9 +314,14 @@ function buildParseFragmentFn(

htmlFragment += strings[strings.length - 1];

cache[cacheKey] = createFragmentFn(htmlFragment, renderer);
const element = createFragmentFn(htmlFragment, renderer);

// Cache is only here to prevent calling innerHTML multiple times which doesn't happen on the server.
if (process.env.IS_BROWSER) {
setInFragmentCache(cacheKey, strings, element);
}

return cache[cacheKey];
return element;
};
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { createElement } from 'lwc';
import { LOWERCASE_SCOPE_TOKENS } from 'test-utils';

import NativeScopedStyles from 'x/nativeScopedStyles';
import NativeStyles from 'x/nativeStyles';
import NoStyles from 'x/noStyles';
import ScopedStyles from 'x/scopedStyles';
import Styles from 'x/styles';

const scenarios = [
{
name: 'no styles',
Ctor: NoStyles,
tagName: 'x-no-styles',
expectedColor: 'rgb(0, 0, 0)',
expectClass: false,
expectAttribute: false,
},
{
name: 'styles',
Ctor: Styles,
tagName: 'x-styles',
expectedColor: 'rgb(255, 0, 0)',
expectClass: false,
expectAttribute: !process.env.NATIVE_SHADOW,
},
{
name: 'scoped styles',
Ctor: ScopedStyles,
tagName: 'x-scoped-styles',
expectedColor: 'rgb(0, 128, 0)',
expectClass: true,
expectAttribute: !process.env.NATIVE_SHADOW,
},
{
name: 'native styles',
Ctor: NativeStyles,
tagName: 'x-native-styles',
expectedColor: 'rgb(255, 0, 0)',
expectClass: false,
expectAttribute: false,
},
{
name: 'native scoped styles',
Ctor: NativeScopedStyles,
tagName: 'x-native-scoped-styles',
expectedColor: 'rgb(0, 128, 0)',
expectClass: true,
expectAttribute: false,
},
];

// These tests confirm that the fragment cache (from `fragment-cache.ts`) is working correctly. Fragments should be
// unique based on 1) synthetic vs native shadow, and 2) presence or absence of scoped styles. If the fragment cache is
// not working correctly, then we may end up rendering the wrong styles or the wrong attribute/class scope token due to
// the cache being poisoned, e.g. an HTML string for scoped styles being rendered for non-scoped styles.
// To test this, we re-use the same `template.html` but change the `static stylesheets` in each component.
scenarios.forEach(({ name, Ctor, tagName, expectedColor, expectClass, expectAttribute }) => {
describe(name, () => {
let h1;

beforeEach(async () => {
const elm = createElement(tagName, { is: Ctor });
document.body.appendChild(elm);
await Promise.resolve();
h1 = elm.shadowRoot.querySelector('h1');
});

it('renders the correct styles', () => {
expect(getComputedStyle(h1).color).toBe(expectedColor);
});

it('renders the correct attributes/classes', () => {
const scopeToken = LOWERCASE_SCOPE_TOKENS ? 'lwc-2it5vhebv0i' : 'x-template_template';

expect(h1.getAttribute('class')).toBe(expectClass ? scopeToken : null);
expect(h1.hasAttribute(scopeToken)).toBe(expectAttribute);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { LightningElement } from 'lwc';
import template from '../template/template.html';
import styles from '../stylesheets/scopedStyles.scoped.css';

export default class extends LightningElement {
static shadowSupportMode = 'native';
static stylesheets = [styles];

render() {
return template;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { LightningElement } from 'lwc';
import template from '../template/template.html';
import styles from '../stylesheets/styles.css';

export default class extends LightningElement {
static shadowSupportMode = 'native';
static stylesheets = [styles];

render() {
return template;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { LightningElement } from 'lwc';
import template from '../template/template.html';

export default class extends LightningElement {
render() {
return template;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { LightningElement } from 'lwc';
import template from '../template/template.html';
import styles from '../stylesheets/scopedStyles.scoped.css';

export default class extends LightningElement {
static stylesheets = [styles];

render() {
return template;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { LightningElement } from 'lwc';
import template from '../template/template.html';
import styles from '../stylesheets/styles.css';

export default class extends LightningElement {
static stylesheets = [styles];

render() {
return template;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
h1 {
color: green;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
h1 {
color: red;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<h1>hello</h1>
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ describe('legacy scope tokens', () => {
setFeatureFlagForTest('ENABLE_LEGACY_SCOPE_TOKENS', false);
// We keep a cache of parsed static fragments; these need to be reset
// since they can vary based on whether we use the legacy scope token or not.
window.__lwcResetFragmentCaches();
window.__lwcResetFragmentCache();
});

function getAttributes(elm) {
Expand Down
10 changes: 8 additions & 2 deletions packages/@lwc/shared/src/language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,12 @@ const {
setPrototypeOf,
} = Object;

/** Detached {@linkcode Array.isArray}; see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray MDN Reference}. */
const { isArray } = Array;
const {
/** Detached {@linkcode Array.isArray}; see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray MDN Reference}. */
isArray,
/** Detached {@linkcode Array.from}; see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from MDN Reference}. */
from: ArrayFrom,
} = Array;

/** The most extensible array type. */
type BaseArray = readonly unknown[];
Expand Down Expand Up @@ -152,6 +156,8 @@ export {
*/
/** Detached {@linkcode Array.isArray}; see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray MDN Reference}. */
isArray,
/** Detached {@linkcode Array.from}; see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from MDN Reference}. */
ArrayFrom,
/*
* Array prototype
*/
Expand Down

0 comments on commit fd3c9e6

Please sign in to comment.