From 37404779e0ab0cd90506c93d26db21aaa96c0dee Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sat, 11 Nov 2023 14:42:47 -0800 Subject: [PATCH 001/113] feat: upgrade to svelte v5 --- config/svelte.config.js | 3 +- package.json | 2 +- rollup.config.js | 3 +- src/picker/components/Picker/Picker.html | 2 +- yarn.lock | 75 ++++++++++-------------- 5 files changed, 38 insertions(+), 47 deletions(-) diff --git a/config/svelte.config.js b/config/svelte.config.js index 116d5410..95df2bd7 100644 --- a/config/svelte.config.js +++ b/config/svelte.config.js @@ -1,5 +1,6 @@ import preprocess from 'svelte-preprocess' export default { - preprocess: preprocess() + preprocess: preprocess(), + emitCss: false } diff --git a/package.json b/package.json index 5113f87a..f5def167 100644 --- a/package.json +++ b/package.json @@ -121,7 +121,7 @@ "stylelint": "^15.11.0", "stylelint-config-recommended-scss": "^13.1.0", "stylelint-scss": "^5.3.1", - "svelte": "^4.2.3", + "svelte": "^5.0.0-next.1", "svelte-jester": "^3.0.0", "svelte-preprocess": "^5.1.0", "svgo": "^3.0.3", diff --git a/rollup.config.js b/rollup.config.js index 0b48b596..7516cad7 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -47,7 +47,8 @@ const baseConfig = { dev, discloseVersion: false }, - preprocess: preprocessConfig + preprocess: preprocessConfig, + emitCss: false }), // make the svelte output slightly smaller replace({ diff --git a/src/picker/components/Picker/Picker.html b/src/picker/components/Picker/Picker.html index 70500d2d..95faa1fa 100644 --- a/src/picker/components/Picker/Picker.html +++ b/src/picker/components/Picker/Picker.html @@ -1,4 +1,4 @@ -
Date: Sat, 11 Nov 2023 14:51:36 -0800 Subject: [PATCH 002/113] fix: svelte v4->v5 bugs --- src/picker/PickerElement.js | 3 ++- src/picker/components/Picker/Picker.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/picker/PickerElement.js b/src/picker/PickerElement.js index ab07c60a..3257deed 100644 --- a/src/picker/PickerElement.js +++ b/src/picker/PickerElement.js @@ -1,3 +1,4 @@ +import { createRoot } from 'svelte' import SveltePicker from './components/Picker/Picker.svelte' import { DEFAULT_DATA_SOURCE, DEFAULT_LOCALE } from '../database/constants' import { DEFAULT_CATEGORY_SORTING, DEFAULT_SKIN_TONE_EMOJI, FONT_FAMILY } from './constants' @@ -51,7 +52,7 @@ export default class PickerElement extends HTMLElement { // The _cmp may be defined if the component was immediately disconnected and then reconnected. In that case, // do nothing (preserve the state) if (!this._cmp) { - this._cmp = new SveltePicker({ + this._cmp = createRoot(SveltePicker, { target: this.shadowRoot, props: this._ctx }) diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index 6f6d0b48..6d707db4 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -58,7 +58,7 @@ let isRtl = false // eslint-disable-line no-unused-vars let scrollbarWidth = 0 // eslint-disable-line no-unused-vars let currentGroupIndex = 0 let groups = defaultGroups -let currentGroup +let currentGroup = groups[currentGroupIndex] let databaseLoaded = false // eslint-disable-line no-unused-vars let activeSearchItemId // eslint-disable-line no-unused-vars From a70518c4d02f65ec112dde167fda74eadd081ea1 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sat, 11 Nov 2023 15:00:02 -0800 Subject: [PATCH 003/113] fix: use snippets --- src/picker/components/Picker/Picker.html | 48 ++++++++++-------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/src/picker/components/Picker/Picker.html b/src/picker/components/Picker/Picker.html index 95faa1fa..495c4400 100644 --- a/src/picker/components/Picker/Picker.html +++ b/src/picker/components/Picker/Picker.html @@ -1,3 +1,20 @@ +{#snippet emojiList((emojis, searchMode, idPrefix)} + {#each emojis as emoji, i (emoji.id)} + + {/each} +{/snippet} +
- {#each emojiWithCategory.emojis as emoji, i (emoji.id)} - - {/each} + {@render emojiList(emojiWithCategory.emojis, searchMode, 'emo')} {/each} @@ -176,21 +180,7 @@ aria-label={i18n.favoritesLabel} style="padding-inline-end: {scrollbarWidth}px" on:click={onEmojiClick}> - - {#each currentFavorites as emoji, i (emoji.id)} - - {/each} + {@render emojiList(favorites, false, 'fav')} From 449e42f86cadbf77b0d032c2d130b045e9ed5768 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sat, 11 Nov 2023 15:00:07 -0800 Subject: [PATCH 004/113] Revert "fix: use snippets" This reverts commit a70518c4d02f65ec112dde167fda74eadd081ea1. --- src/picker/components/Picker/Picker.html | 48 ++++++++++++++---------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/src/picker/components/Picker/Picker.html b/src/picker/components/Picker/Picker.html index 495c4400..95faa1fa 100644 --- a/src/picker/components/Picker/Picker.html +++ b/src/picker/components/Picker/Picker.html @@ -1,20 +1,3 @@ -{#snippet emojiList((emojis, searchMode, idPrefix)} - {#each emojis as emoji, i (emoji.id)} - - {/each} -{/snippet} -
- {@render emojiList(emojiWithCategory.emojis, searchMode, 'emo')} + {#each emojiWithCategory.emojis as emoji, i (emoji.id)} + + {/each} {/each} @@ -180,7 +176,21 @@ aria-label={i18n.favoritesLabel} style="padding-inline-end: {scrollbarWidth}px" on:click={onEmojiClick}> - {@render emojiList(favorites, false, 'fav')} + + {#each currentFavorites as emoji, i (emoji.id)} + + {/each} From 8b1543908652155e24645b2c38ff9b0bca7dbf0f Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sun, 12 Nov 2023 05:05:29 -0800 Subject: [PATCH 005/113] chore: bump bundlesize --- bin/bundlesize.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/bundlesize.js b/bin/bundlesize.js index c699be73..02510106 100644 --- a/bin/bundlesize.js +++ b/bin/bundlesize.js @@ -5,8 +5,8 @@ import { promisify } from 'node:util' import prettyBytes from 'pretty-bytes' import fs from 'node:fs/promises' -const MAX_SIZE_MIN = '42.7 kB' -const MAX_SIZE_MINGZ = '15 kB' +const MAX_SIZE_MIN = '53 kB' +const MAX_SIZE_MINGZ = '19 kB' const FILENAME = './bundle.js' From 66f35ab5109a4db966356fa738eba9061b60f038 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sun, 12 Nov 2023 05:13:10 -0800 Subject: [PATCH 006/113] fix: remove when unmounting --- src/picker/PickerElement.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/picker/PickerElement.js b/src/picker/PickerElement.js index 3257deed..a5c76502 100644 --- a/src/picker/PickerElement.js +++ b/src/picker/PickerElement.js @@ -67,6 +67,10 @@ export default class PickerElement extends HTMLElement { if (!this.isConnected && this._cmp) { this._cmp.$destroy() this._cmp = undefined + const picker = this.shadowRoot.querySelector('.picker') + if (picker) { + picker.remove() + } const { database } = this._ctx database.close() From 56da85877b145b11319d343240aaad7cc4a43219 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sun, 12 Nov 2023 05:18:01 -0800 Subject: [PATCH 007/113] fix: coverage --- src/picker/PickerElement.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/picker/PickerElement.js b/src/picker/PickerElement.js index a5c76502..8cd58da7 100644 --- a/src/picker/PickerElement.js +++ b/src/picker/PickerElement.js @@ -68,6 +68,8 @@ export default class PickerElement extends HTMLElement { this._cmp.$destroy() this._cmp = undefined const picker = this.shadowRoot.querySelector('.picker') + // This is never undefined, but I'd feel more comfortable with the `if` anyway + /* istanbul ignore else */ if (picker) { picker.remove() } From cccd75968927606b462a11838209c75649c340b9 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Wed, 15 Nov 2023 07:07:57 -0800 Subject: [PATCH 008/113] fix: update again --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index db420500..ade8844d 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "stylelint": "^15.11.0", "stylelint-config-recommended-scss": "^13.1.0", "stylelint-scss": "^5.3.1", - "svelte": "^5.0.0-next.1", + "svelte": "^5.0.0-next.2", "svelte-jester": "^3.0.0", "svelte-preprocess": "^5.1.0", "svgo": "^3.0.3", diff --git a/yarn.lock b/yarn.lock index c0714deb..4593d0c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7614,10 +7614,10 @@ svelte-preprocess@^5.1.0: sorcery "^0.11.0" strip-indent "^3.0.0" -svelte@^5.0.0-next.1: - version "5.0.0-next.1" - resolved "https://registry.yarnpkg.com/svelte/-/svelte-5.0.0-next.1.tgz#4aafe007a3751c152137ad007f42e6224661ba24" - integrity sha512-AhepUparoLMcPmZsExxMKAzeohdEaV3FV9PWfWZksbygEBO5MAixz4pysfhIQJ4RQbaWipllqrds/nmxr0F64w== +svelte@^5.0.0-next.2: + version "5.0.0-next.2" + resolved "https://registry.yarnpkg.com/svelte/-/svelte-5.0.0-next.2.tgz#302590de1e52d8aafb1199fbb393857478ec9b5e" + integrity sha512-fEB9A+KbZi+a21Baw+RbBQ49Gq35tqqKeWB2xrPV7tbKZT7E1sJMq0ZDNk72L5y29+N33FPnwH8v5LBFAD/PGw== dependencies: "@ampproject/remapping" "^2.2.1" "@jridgewell/sourcemap-codec" "^1.4.15" From 02c1a201c39f2bb52af6b046467dabb0cd709190 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Wed, 15 Nov 2023 07:09:12 -0800 Subject: [PATCH 009/113] Revert "Revert "fix: use snippets"" This reverts commit 449e42f86cadbf77b0d032c2d130b045e9ed5768. --- src/picker/components/Picker/Picker.html | 48 ++++++++++-------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/src/picker/components/Picker/Picker.html b/src/picker/components/Picker/Picker.html index 95faa1fa..495c4400 100644 --- a/src/picker/components/Picker/Picker.html +++ b/src/picker/components/Picker/Picker.html @@ -1,3 +1,20 @@ +{#snippet emojiList((emojis, searchMode, idPrefix)} + {#each emojis as emoji, i (emoji.id)} + + {/each} +{/snippet} +
- {#each emojiWithCategory.emojis as emoji, i (emoji.id)} - - {/each} + {@render emojiList(emojiWithCategory.emojis, searchMode, 'emo')} {/each} @@ -176,21 +180,7 @@ aria-label={i18n.favoritesLabel} style="padding-inline-end: {scrollbarWidth}px" on:click={onEmojiClick}> - - {#each currentFavorites as emoji, i (emoji.id)} - - {/each} + {@render emojiList(favorites, false, 'fav')} From 30215fd01d437f5432f6f11381552ecab1522887 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Wed, 15 Nov 2023 07:20:06 -0800 Subject: [PATCH 010/113] fix: fix snippets --- src/picker/components/Picker/Picker.html | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/picker/components/Picker/Picker.html b/src/picker/components/Picker/Picker.html index 495c4400..da6efa55 100644 --- a/src/picker/components/Picker/Picker.html +++ b/src/picker/components/Picker/Picker.html @@ -1,11 +1,14 @@ -{#snippet emojiList((emojis, searchMode, idPrefix)} +{#snippet emojiList(args)} + {@const emojis = args[0]} + {@const searchMode = args[1]} + {@const prefix = args[2]} {#each emojis as emoji, i (emoji.id)} From 1ecd040135fd372a8b8f2a24611e3ddb4dcb8943 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Wed, 15 Nov 2023 07:30:17 -0800 Subject: [PATCH 011/113] fix: reduce size using hacks --- rollup.config.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/rollup.config.js b/rollup.config.js index 3a58068c..db0953f2 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -53,11 +53,16 @@ const baseConfig = { }), // make the svelte output slightly smaller replace({ - 'options.anchor': 'undefined', 'options.context': 'undefined', - 'options.customElement': 'undefined', - 'options.hydrate': 'undefined', + 'options.events': 'undefined', + 'options.immutable': 'false', 'options.intro': 'undefined', + 'options.recover': 'false', + 'current_hydration_fragment !== null': 'false', + 'current_hydration_fragment === null': 'true', + 'hydration_fragment === null': 'true', + 'hydration_fragment !== null': 'false', + 'get_hydration_fragment(first_child)': 'null', delimiters: ['', ''], preventAssignment: true }), From 08dac46097e41970641a85f13ec31000f7a9df4c Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Wed, 15 Nov 2023 07:34:28 -0800 Subject: [PATCH 012/113] perf: remove transitions code --- rollup.config.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rollup.config.js b/rollup.config.js index db0953f2..b81d5aeb 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -66,6 +66,10 @@ const baseConfig = { delimiters: ['', ''], preventAssignment: true }), + replace({ + 'active_transitions.length': '0', + preventAssignment: true + }), strip({ include: ['**/*.js', '**/*.svelte'], functions: [ From bd0f807c11527fa0e7f6ab64b64275c82c09e961 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Thu, 16 Nov 2023 06:50:34 -0800 Subject: [PATCH 013/113] fix: remove more code --- rollup.config.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/rollup.config.js b/rollup.config.js index b81d5aeb..1b93b257 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -58,16 +58,23 @@ const baseConfig = { 'options.immutable': 'false', 'options.intro': 'undefined', 'options.recover': 'false', + // remove hydration 'current_hydration_fragment !== null': 'false', 'current_hydration_fragment === null': 'true', 'hydration_fragment === null': 'true', 'hydration_fragment !== null': 'false', 'get_hydration_fragment(first_child)': 'null', - delimiters: ['', ''], - preventAssignment: true - }), - replace({ + // remove transitions 'active_transitions.length': '0', + 'alternate_transitions.size': '0', + 'consequent_transitions.size': '0', + 'transitions.size': '0', + 'each_block.transitions': '[]', + 'block.transitions': 'null', + "trigger_transitions(transitions, 'key', from)": '', + "trigger_transitions(transitions, 'out')": '', + '= each_item_transition': '= () => {}', + delimiters: ['', ''], preventAssignment: true }), strip({ From 0d09db2bdbb7bde94847b23fd281eb5c56a20eca Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sat, 18 Nov 2023 12:31:51 -0800 Subject: [PATCH 014/113] fix: revert hacks for now --- rollup.config.js | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/rollup.config.js b/rollup.config.js index 1b93b257..3a58068c 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -53,27 +53,11 @@ const baseConfig = { }), // make the svelte output slightly smaller replace({ + 'options.anchor': 'undefined', 'options.context': 'undefined', - 'options.events': 'undefined', - 'options.immutable': 'false', + 'options.customElement': 'undefined', + 'options.hydrate': 'undefined', 'options.intro': 'undefined', - 'options.recover': 'false', - // remove hydration - 'current_hydration_fragment !== null': 'false', - 'current_hydration_fragment === null': 'true', - 'hydration_fragment === null': 'true', - 'hydration_fragment !== null': 'false', - 'get_hydration_fragment(first_child)': 'null', - // remove transitions - 'active_transitions.length': '0', - 'alternate_transitions.size': '0', - 'consequent_transitions.size': '0', - 'transitions.size': '0', - 'each_block.transitions': '[]', - 'block.transitions': 'null', - "trigger_transitions(transitions, 'key', from)": '', - "trigger_transitions(transitions, 'out')": '', - '= each_item_transition': '= () => {}', delimiters: ['', ''], preventAssignment: true }), From 24cfa3a6c420c85d6db33f9d27747f66d48bc2d5 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sat, 18 Nov 2023 14:41:50 -0800 Subject: [PATCH 015/113] fix: migrate Picker.js to vanilla --- src/picker/components/Picker/Picker.js | 894 +++++++++++++------------ 1 file changed, 453 insertions(+), 441 deletions(-) diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index 6d707db4..aa73eaca 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -1,5 +1,4 @@ /* eslint-disable prefer-const,no-labels,no-inner-declarations */ -import { onMount } from 'svelte' import { groups as defaultGroups, customGroup } from '../../groups' import { MIN_SEARCH_TEXT_LENGTH, NUM_SKIN_TONES } from '../../../shared/constants' import { requestIdleCallback } from '../../utils/requestIdleCallback' @@ -22,216 +21,235 @@ import { requestAnimationFrame } from '../../utils/requestAnimationFrame' import { uniq } from '../../../shared/uniq' import { resetScrollTopIfPossible } from '../../utils/resetScrollTopIfPossible.js' -// public -export let skinToneEmoji -export let i18n -export let database -export let customEmoji -export let customCategorySorting -export let emojiVersion - -// private -let initialLoad = true -let currentEmojis = [] -let currentEmojisWithCategories = [] // eslint-disable-line no-unused-vars -let rawSearchText = '' -let searchText = '' -let rootElement -let baselineEmoji -let tabpanelElement -let searchMode = false // eslint-disable-line no-unused-vars -let activeSearchItem = -1 -let message // eslint-disable-line no-unused-vars -let skinTonePickerExpanded = false -let skinTonePickerExpandedAfterAnimation = false // eslint-disable-line no-unused-vars -let skinToneDropdown -let currentSkinTone = 0 -let activeSkinTone = 0 -let skinToneButtonText // eslint-disable-line no-unused-vars -let pickerStyle // eslint-disable-line no-unused-vars -let skinToneButtonLabel = '' // eslint-disable-line no-unused-vars -let skinTones = [] -let currentFavorites = [] // eslint-disable-line no-unused-vars -let defaultFavoriteEmojis -let numColumns = DEFAULT_NUM_COLUMNS -let isRtl = false // eslint-disable-line no-unused-vars -let scrollbarWidth = 0 // eslint-disable-line no-unused-vars -let currentGroupIndex = 0 -let groups = defaultGroups -let currentGroup = groups[currentGroupIndex] -let databaseLoaded = false // eslint-disable-line no-unused-vars -let activeSearchItemId // eslint-disable-line no-unused-vars - // constants const EMPTY_ARRAY = [] +const { assign } = Object + +export function createRoot(target, props) { + + const state = { + skinToneEmoji: undefined, + i18n: undefined, + database: undefined, + customEmoji: undefined, + customCategorySorting: undefined, + emojiVersion: undefined, + } + + // public + assign(state, props) + + // private + assign(state, { + initialLoad: true, + currentEmojis: [], + currentEmojisWithCategories: [], + rawSearchText: '', + searchText: '', + rootElement: undefined, + baselineEmoji: undefined, + tabpanelElement: undefined, + searchMode: false, + activeSearchItem: -1, + message: undefined, + skinTonePickerExpanded: false, + skinTonePickerExpandedAfterAnimation: false, + skinToneDropdown: undefined, + currentSkinTone: 0, + activeSkinTone: 0, + skinToneButtonText: undefined, + pickerStyle: undefined, + skinToneButtonLabel: '', + skinTones: [], + currentFavorites: [], + defaultFavoriteEmojis: undefined, + numColumns: DEFAULT_NUM_COLUMNS, + isRtl: false, + scrollbarWidth: 0, + currentGroupIndex: 0, + groups: defaultGroups, + databaseLoaded: false, + activeSearchItemId: undefined, + }) + // // Utils/helpers // -const focus = id => { - rootElement.getRootNode().getElementById(id).focus() -} + const focus = id => { + rootElement.getRootNode().getElementById(id).focus() + } // fire a custom event that crosses the shadow boundary -const fireEvent = (name, detail) => { - rootElement.dispatchEvent(new CustomEvent(name, { - detail, - bubbles: true, - composed: true - })) -} + const fireEvent = (name, detail) => { + rootElement.dispatchEvent(new CustomEvent(name, { + detail, + bubbles: true, + composed: true + })) + } // eslint-disable-next-line no-unused-vars -const unicodeWithSkin = (emoji, currentSkinTone) => ( - (currentSkinTone && emoji.skins && emoji.skins[currentSkinTone]) || emoji.unicode -) + const unicodeWithSkin = (emoji, currentSkinTone) => ( + (currentSkinTone && emoji.skins && emoji.skins[currentSkinTone]) || emoji.unicode + ) // eslint-disable-next-line no-unused-vars -const labelWithSkin = (emoji, currentSkinTone) => ( - uniq([ - (emoji.name || unicodeWithSkin(emoji, currentSkinTone)), - emoji.annotation, - ...(emoji.shortcodes || EMPTY_ARRAY) - ].filter(Boolean)).join(', ') -) + const labelWithSkin = (emoji, currentSkinTone) => ( + uniq([ + (emoji.name || unicodeWithSkin(emoji, currentSkinTone)), + emoji.annotation, + ...(emoji.shortcodes || EMPTY_ARRAY) + ].filter(Boolean)).join(', ') + ) // eslint-disable-next-line no-unused-vars -const titleForEmoji = (emoji) => ( - emoji.annotation || (emoji.shortcodes || EMPTY_ARRAY).join(', ') -) + const titleForEmoji = (emoji) => ( + emoji.annotation || (emoji.shortcodes || EMPTY_ARRAY).join(', ') + ) // // Determine the emoji support level (in requestIdleCallback) // -onMount(() => { - if (!emojiVersion) { - detectEmojiSupportLevel().then(level => { - // Can't actually test emoji support in Jest/JSDom, emoji never render in color in Cairo - /* istanbul ignore next */ - if (!level) { - message = i18n.emojiUnsupportedMessage - } - }) - } -}) + // mount logic + if (!state.emojiVersion) { + detectEmojiSupportLevel().then(level => { + // Can't actually test emoji support in Jest/JSDom, emoji never render in color in Cairo + /* istanbul ignore next */ + if (!level) { + state.message = state.i18n.emojiUnsupportedMessage + } + }) + } // // Set or update the database object // -$: { - // show a Loading message if it takes a long time, or show an error if there's a network/IDB error - async function handleDatabaseLoading () { - let showingLoadingMessage = false - const timeoutHandle = setTimeout(() => { - showingLoadingMessage = true - message = i18n.loadingMessage - }, TIMEOUT_BEFORE_LOADING_MESSAGE) - try { - await database.ready() - databaseLoaded = true // eslint-disable-line no-unused-vars - } catch (err) { - console.error(err) - message = i18n.networkErrorMessage - } finally { - clearTimeout(timeoutHandle) - if (showingLoadingMessage) { // Seems safer than checking the i18n string, which may change - showingLoadingMessage = false - message = '' // eslint-disable-line no-unused-vars + createEffect(() => { + // show a Loading message if it takes a long time, or show an error if there's a network/IDB error + async function handleDatabaseLoading () { + let showingLoadingMessage = false + const timeoutHandle = setTimeout(() => { + showingLoadingMessage = true + state.message = state.i18n.loadingMessage + }, TIMEOUT_BEFORE_LOADING_MESSAGE) + try { + await database.ready() + state.databaseLoaded = true // eslint-disable-line no-unused-vars + } catch (err) { + console.error(err) + state.message = state.i18n.networkErrorMessage + } finally { + clearTimeout(timeoutHandle) + if (showingLoadingMessage) { // Seems safer than checking the i18n string, which may change + showingLoadingMessage = false + state.message = '' // eslint-disable-line no-unused-vars + } } } - } - if (database) { - /* no await */ handleDatabaseLoading() - } -} + + if (state.database) { + /* no await */ + handleDatabaseLoading() + } + }) // // Global styles for the entire picker // -/* eslint-disable no-unused-vars */ -$: pickerStyle = ` - --num-groups: ${groups.length}; - --indicator-opacity: ${searchMode ? 0 : 1}; - --num-skintones: ${NUM_SKIN_TONES};` -/* eslint-enable no-unused-vars */ + createEffect(() => { + state.pickerStyle = ` + --num-groups: ${state.groups.length}; + --indicator-opacity: ${state.searchMode ? 0 : 1}; + --num-skintones: ${NUM_SKIN_TONES};` + }) // // Set or update the customEmoji // -$: { - if (customEmoji && database) { - console.log('updating custom emoji') - database.customEmoji = customEmoji - } -} - -$: { - if (customEmoji && customEmoji.length) { - groups = [customGroup, ...defaultGroups] - } else if (groups !== defaultGroups) { - if (currentGroupIndex) { - // If the current group is anything other than "custom" (which is first), decrement. - // This fixes the odd case where you set customEmoji, then pick a category, then unset customEmoji - currentGroupIndex-- + createEffect(() => { + if (state.customEmoji && state.database) { + console.log('updating custom emoji') + state.database.customEmoji = state.customEmoji } - groups = defaultGroups - } -} + }) + + createEffect(() => { + if (state.customEmoji && state.customEmoji.length) { + state.groups = [customGroup, ...defaultGroups] + } else if (state.groups !== defaultGroups) { + if (state.currentGroupIndex) { + // If the current group is anything other than "custom" (which is first), decrement. + // This fixes the odd case where you set customEmoji, then pick a category, then unset customEmoji + state.currentGroupIndex-- + } + state.groups = defaultGroups + } + }) // // Set or update the preferred skin tone // -$: { - async function updatePreferredSkinTone () { - if (databaseLoaded) { - currentSkinTone = await database.getPreferredSkinTone() + createEffect(() => { + async function updatePreferredSkinTone () { + if (state.databaseLoaded) { + state.currentSkinTone = await state.database.getPreferredSkinTone() + } } - } - /* no await */ updatePreferredSkinTone() -} -$: skinTones = Array(NUM_SKIN_TONES).fill().map((_, i) => applySkinTone(skinToneEmoji, i)) -/* eslint-disable no-unused-vars */ -$: skinToneButtonText = skinTones[currentSkinTone] -$: skinToneButtonLabel = i18n.skinToneLabel.replace('{skinTone}', i18n.skinTones[currentSkinTone]) -/* eslint-enable no-unused-vars */ + /* no await */ updatePreferredSkinTone() + }) + + createEffect(() => { + state.skinTones = Array(NUM_SKIN_TONES).fill().map((_, i) => applySkinTone(state.skinToneEmoji, i)) + }) + +createEffect(() => { + state.skinToneButtonText = state.skinTones[state.currentSkinTone] +}) + + createEffect(() => { + state.skinToneButtonLabel = state.i18n.skinToneLabel.replace('{skinTone}', state.i18n.skinTones[state.currentSkinTone]) + }) // // Set or update the favorites emojis // -$: { - async function updateDefaultFavoriteEmojis () { - defaultFavoriteEmojis = (await Promise.all(MOST_COMMONLY_USED_EMOJI.map(unicode => ( - database.getEmojiByUnicodeOrName(unicode) - )))).filter(Boolean) // filter because in Jest tests we don't have all the emoji in the DB - } - if (databaseLoaded) { - /* no await */ updateDefaultFavoriteEmojis() - } -} - -$: { - async function updateFavorites () { - console.log('updateFavorites') - const dbFavorites = await database.getTopFavoriteEmoji(numColumns) - const favorites = await summarizeEmojis(uniqBy([ - ...dbFavorites, - ...defaultFavoriteEmojis - ], _ => (_.unicode || _.name)).slice(0, numColumns)) - currentFavorites = favorites - } + createEffect(() => { + async function updateDefaultFavoriteEmojis () { + state.defaultFavoriteEmojis = (await Promise.all(MOST_COMMONLY_USED_EMOJI.map(unicode => ( + state.database.getEmojiByUnicodeOrName(unicode) + )))).filter(Boolean) // filter because in Jest tests we don't have all the emoji in the DB + } - if (databaseLoaded && defaultFavoriteEmojis) { - /* no await */ updateFavorites() - } -} + if (state.databaseLoaded) { + /* no await */ updateDefaultFavoriteEmojis() + } + }) + + createEffect(() => { + async function updateFavorites () { + console.log('updateFavorites') + const { database, defaultFavoriteEmojis, numColumns } = state + const dbFavorites = await database.getTopFavoriteEmoji(numColumns) + const favorites = await summarizeEmojis(uniqBy([ + ...dbFavorites, + ...defaultFavoriteEmojis + ], _ => (_.unicode || _.name)).slice(0, numColumns)) + state.currentFavorites = favorites + } + + if (state.databaseLoaded && state.defaultFavoriteEmojis) { + /* no await */ updateFavorites() + } + }) // // Calculate the width of the emoji grid. This serves two purposes: @@ -246,366 +264,360 @@ $: { // the rAF loop, this is the most appropriate place to do it perf-wise. // -// eslint-disable-next-line no-unused-vars -function calculateEmojiGridStyle (node) { - return widthCalculator.calculateWidth(node, width => { - /* istanbul ignore next */ - if (process.env.NODE_ENV !== 'test') { // jsdom throws errors for this kind of fancy stuff - // read all the style/layout calculations we need to make - const style = getComputedStyle(rootElement) - const newNumColumns = parseInt(style.getPropertyValue('--num-columns'), 10) - const newIsRtl = style.getPropertyValue('direction') === 'rtl' - const parentWidth = node.parentElement.getBoundingClientRect().width - const newScrollbarWidth = parentWidth - width - - // write to Svelte variables - numColumns = newNumColumns - scrollbarWidth = newScrollbarWidth // eslint-disable-line no-unused-vars - isRtl = newIsRtl // eslint-disable-line no-unused-vars - } - }) -} + function calculateEmojiGridStyle (node) { + return widthCalculator.calculateWidth(node, width => { + /* istanbul ignore next */ + if (process.env.NODE_ENV !== 'test') { // jsdom throws errors for this kind of fancy stuff + // read all the style/layout calculations we need to make + const style = getComputedStyle(rootElement) + const newNumColumns = parseInt(style.getPropertyValue('--num-columns'), 10) + const newIsRtl = style.getPropertyValue('direction') === 'rtl' + const parentWidth = node.parentElement.getBoundingClientRect().width + const newScrollbarWidth = parentWidth - width + + // write to state variables + state.numColumns = newNumColumns + state.scrollbarWidth = newScrollbarWidth // eslint-disable-line no-unused-vars + state.isRtl = newIsRtl // eslint-disable-line no-unused-vars + } + }) + } // // Update the current group based on the currentGroupIndex // -$: currentGroup = groups[currentGroupIndex] + createEffect(() => { + state.currentGroup = state.groups[state.currentGroupIndex] + }) // // Set or update the currentEmojis. Check for invalid ZWJ renderings // (i.e. double emoji). // -$: { - async function updateEmojis () { - console.log('updateEmojis') - if (!databaseLoaded) { - currentEmojis = [] - searchMode = false - } else if (searchText.length >= MIN_SEARCH_TEXT_LENGTH) { - const currentSearchText = searchText - const newEmojis = await getEmojisBySearchQuery(currentSearchText) - if (currentSearchText === searchText) { // if the situation changes asynchronously, do not update - currentEmojis = newEmojis - searchMode = true - } - } else if (currentGroup) { - const currentGroupId = currentGroup.id - const newEmojis = await getEmojisByGroup(currentGroupId) - if (currentGroupId === currentGroup.id) { // if the situation changes asynchronously, do not update - currentEmojis = newEmojis - searchMode = false + createEffect(() => { + async function updateEmojis () { + console.log('updateEmojis') + const { searchText, currentGroup, databaseLoaded } = state + if (!databaseLoaded) { + state.currentEmojis = [] + state.searchMode = false + } else if (searchText.length >= MIN_SEARCH_TEXT_LENGTH) { + const newEmojis = await getEmojisBySearchQuery(searchText) + if (state.searchText === searchText) { // if the situation changes asynchronously, do not update + state.currentEmojis = newEmojis + state.searchMode = true + } + } else if (currentGroup) { + const { id: currentGroupId } = currentGroup + const newEmojis = await getEmojisByGroup(currentGroupId) + if (state.currentGroup.id === currentGroupId) { // if the situation changes asynchronously, do not update + state.currentEmojis = newEmojis + state.searchMode = false + } } } - } - /* no await */ updateEmojis() -} + + /* no await */ updateEmojis() + }) // Some emojis have their ligatures rendered as two or more consecutive emojis // We want to treat these the same as unsupported emojis, so we compare their // widths against the baseline widths and remove them as necessary -$: { - const zwjEmojisToCheck = currentEmojis - .filter(emoji => emoji.unicode) // filter custom emoji - .filter(emoji => hasZwj(emoji) && !supportedZwjEmojis.has(emoji.unicode)) - if (!emojiVersion && zwjEmojisToCheck.length) { - // render now, check their length later - requestAnimationFrame(() => checkZwjSupportAndUpdate(zwjEmojisToCheck)) - } else { - currentEmojis = emojiVersion ? currentEmojis : currentEmojis.filter(isZwjSupported) - // Reset scroll top to 0 when emojis change - requestAnimationFrame(() => resetScrollTopIfPossible(tabpanelElement)) - } -} - -function checkZwjSupportAndUpdate (zwjEmojisToCheck) { - const rootNode = rootElement.getRootNode() - const emojiToDomNode = emoji => rootNode.getElementById(`emo-${emoji.id}`) - checkZwjSupport(zwjEmojisToCheck, baselineEmoji, emojiToDomNode) - // force update - currentEmojis = currentEmojis // eslint-disable-line no-self-assign -} - -function isZwjSupported (emoji) { - return !emoji.unicode || !hasZwj(emoji) || supportedZwjEmojis.get(emoji.unicode) -} - -async function filterEmojisByVersion (emojis) { - const emojiSupportLevel = emojiVersion || await detectEmojiSupportLevel() - // !version corresponds to custom emoji - return emojis.filter(({ version }) => !version || version <= emojiSupportLevel) -} - -async function summarizeEmojis (emojis) { - return summarizeEmojisForUI(emojis, emojiVersion || await detectEmojiSupportLevel()) -} - -async function getEmojisByGroup (group) { - console.log('getEmojiByGroup', group) - // -1 is custom emoji - const emoji = group === -1 ? customEmoji : await database.getEmojiByGroup(group) - return summarizeEmojis(await filterEmojisByVersion(emoji)) -} - -async function getEmojisBySearchQuery (query) { - return summarizeEmojis(await filterEmojisByVersion(await database.getEmojiBySearchQuery(query))) -} - -$: { - // consider initialLoad to be complete when the first tabpanel and favorites are rendered - /* istanbul ignore next */ - if (process.env.NODE_ENV !== 'production' || process.env.PERF) { - if (currentEmojis.length && currentFavorites.length && initialLoad) { - initialLoad = false - requestPostAnimationFrame(() => performance.measure('initialLoad', 'initialLoad')) + createEffect(() => { + const zwjEmojisToCheck = state.currentEmojis + .filter(emoji => emoji.unicode) // filter custom emoji + .filter(emoji => hasZwj(emoji) && !supportedZwjEmojis.has(emoji.unicode)) + if (!state.emojiVersion && zwjEmojisToCheck.length) { + // render now, check their length later + requestAnimationFrame(() => checkZwjSupportAndUpdate(zwjEmojisToCheck)) + } else { + state.currentEmojis = state.emojiVersion ? state.currentEmojis : state.currentEmojis.filter(isZwjSupported) + // Reset scroll top to 0 when emojis change + requestAnimationFrame(() => resetScrollTopIfPossible(tabpanelElement)) } + }) + + function checkZwjSupportAndUpdate (zwjEmojisToCheck) { + const rootNode = rootElement.getRootNode() + const emojiToDomNode = emoji => rootNode.getElementById(`emo-${emoji.id}`) + checkZwjSupport(zwjEmojisToCheck, baselineEmoji, emojiToDomNode) + // force update + state.currentEmojis = currentEmojis // eslint-disable-line no-self-assign + } + + function isZwjSupported (emoji) { + return !emoji.unicode || !hasZwj(emoji) || supportedZwjEmojis.get(emoji.unicode) + } + + async function filterEmojisByVersion (emojis) { + const emojiSupportLevel = state.emojiVersion || await detectEmojiSupportLevel() + // !version corresponds to custom emoji + return emojis.filter(({ version }) => !version || version <= emojiSupportLevel) + } + + async function summarizeEmojis (emojis) { + return summarizeEmojisForUI(emojis, state.emojiVersion || await detectEmojiSupportLevel()) } -} + + async function getEmojisByGroup (group) { + console.log('getEmojiByGroup', group) + // -1 is custom emoji + const emoji = group === -1 ? state.customEmoji : await state.database.getEmojiByGroup(group) + return summarizeEmojis(await filterEmojisByVersion(emoji)) + } + + async function getEmojisBySearchQuery (query) { + return summarizeEmojis(await filterEmojisByVersion(await state.database.getEmojiBySearchQuery(query))) + } + + createEffect(() => { + // consider initialLoad to be complete when the first tabpanel and favorites are rendered + /* istanbul ignore next */ + if (process.env.NODE_ENV !== 'production' || process.env.PERF) { + if (state.currentEmojis.length && state.currentFavorites.length && state.initialLoad) { + state.initialLoad = false + requestPostAnimationFrame(() => performance.measure('initialLoad', 'initialLoad')) + } + } + }) // // Derive currentEmojisWithCategories from currentEmojis. This is always done even if there // are no categories, because it's just easier to code the HTML this way. // -$: { - function calculateCurrentEmojisWithCategories () { - if (searchMode) { - return [ - { - category: '', - emojis: currentEmojis + createEffect(() => { + function calculateCurrentEmojisWithCategories () { + if (state.searchMode) { + return [ + { + category: '', + emojis: state.currentEmojis + } + ] + } + const categoriesToEmoji = new Map() + for (const emoji of state.currentEmojis) { + const category = emoji.category || '' + let emojis = categoriesToEmoji.get(category) + if (!emojis) { + emojis = [] + categoriesToEmoji.set(category, emojis) } - ] - } - const categoriesToEmoji = new Map() - for (const emoji of currentEmojis) { - const category = emoji.category || '' - let emojis = categoriesToEmoji.get(category) - if (!emojis) { - emojis = [] - categoriesToEmoji.set(category, emojis) + emojis.push(emoji) } - emojis.push(emoji) + return [...categoriesToEmoji.entries()] + .map(([category, emojis]) => ({ category, emojis })) + .sort((a, b) => state.customCategorySorting(a.category, b.category)) } - return [...categoriesToEmoji.entries()] - .map(([category, emojis]) => ({ category, emojis })) - .sort((a, b) => customCategorySorting(a.category, b.category)) - } - // eslint-disable-next-line no-unused-vars - currentEmojisWithCategories = calculateCurrentEmojisWithCategories() -} + state.currentEmojisWithCategories = calculateCurrentEmojisWithCategories() + }) // // Handle active search item (i.e. pressing up or down while searching) // -/* eslint-disable no-unused-vars */ -$: activeSearchItemId = activeSearchItem !== -1 && currentEmojis[activeSearchItem].id -/* eslint-enable no-unused-vars */ + createEffect(() => { + state.activeSearchItemId = state.activeSearchItem !== -1 && state.currentEmojis[state.activeSearchItem].id + }) // // Handle user input on the search input // -$: { - requestIdleCallback(() => { - searchText = (rawSearchText || '').trim() // defer to avoid input delays, plus we can trim here - activeSearchItem = -1 + createEffect(() => { + const { rawSearchText } = state + requestIdleCallback(() => { + state.searchText = (rawSearchText || '').trim() // defer to avoid input delays, plus we can trim here + state.activeSearchItem = -1 + }) }) -} -// eslint-disable-next-line no-unused-vars -function onSearchKeydown (event) { - if (!searchMode || !currentEmojis.length) { - return - } + function onSearchKeydown (event) { + if (!state.searchMode || !state.currentEmojis.length) { + return + } - const goToNextOrPrevious = (previous) => { - halt(event) - activeSearchItem = incrementOrDecrement(previous, activeSearchItem, currentEmojis) - } + const goToNextOrPrevious = (previous) => { + halt(event) + state.activeSearchItem = incrementOrDecrement(previous, state.activeSearchItem, state.currentEmojis) + } - switch (event.key) { - case 'ArrowDown': - return goToNextOrPrevious(false) - case 'ArrowUp': - return goToNextOrPrevious(true) - case 'Enter': - if (activeSearchItem !== -1) { - halt(event) - return clickEmoji(currentEmojis[activeSearchItem].id) - } else if (currentEmojis.length) { - activeSearchItem = 0 - } + switch (event.key) { + case 'ArrowDown': + return goToNextOrPrevious(false) + case 'ArrowUp': + return goToNextOrPrevious(true) + case 'Enter': + if (state.activeSearchItem !== -1) { + halt(event) + return clickEmoji(state.currentEmojis[state.activeSearchItem].id) + } else if (state.currentEmojis.length) { + state.activeSearchItem = 0 + } + } } -} // // Handle user input on nav // -// eslint-disable-next-line no-unused-vars -function onNavClick (group) { - rawSearchText = '' - searchText = '' - activeSearchItem = -1 - currentGroupIndex = groups.findIndex(_ => _.id === group.id) -} + function onNavClick (group) { + state.rawSearchText = '' + state.searchText = '' + state.activeSearchItem = -1 + state.currentGroupIndex = state.groups.findIndex(_ => _.id === group.id) + } -// eslint-disable-next-line no-unused-vars -function onNavKeydown (event) { - const { target, key } = event + function onNavKeydown (event) { + const { target, key } = event - const doFocus = el => { - if (el) { - halt(event) - el.focus() + const doFocus = el => { + if (el) { + halt(event) + el.focus() + } } - } - switch (key) { - case 'ArrowLeft': - return doFocus(target.previousSibling) - case 'ArrowRight': - return doFocus(target.nextSibling) - case 'Home': - return doFocus(target.parentElement.firstChild) - case 'End': - return doFocus(target.parentElement.lastChild) + switch (key) { + case 'ArrowLeft': + return doFocus(target.previousSibling) + case 'ArrowRight': + return doFocus(target.nextSibling) + case 'Home': + return doFocus(target.parentElement.firstChild) + case 'End': + return doFocus(target.parentElement.lastChild) + } } -} // // Handle user input on an emoji // -async function clickEmoji (unicodeOrName) { - const emoji = await database.getEmojiByUnicodeOrName(unicodeOrName) - const emojiSummary = [...currentEmojis, ...currentFavorites] - .find(_ => (_.id === unicodeOrName)) - const skinTonedUnicode = emojiSummary.unicode && unicodeWithSkin(emojiSummary, currentSkinTone) - await database.incrementFavoriteEmojiCount(unicodeOrName) - fireEvent('emoji-click', { - emoji, - skinTone: currentSkinTone, - ...(skinTonedUnicode && { unicode: skinTonedUnicode }), - ...(emojiSummary.name && { name: emojiSummary.name }) - }) -} - -// eslint-disable-next-line no-unused-vars -async function onEmojiClick (event) { - const { target } = event - if (!target.classList.contains('emoji')) { - return + async function clickEmoji (unicodeOrName) { + const emoji = await state.database.getEmojiByUnicodeOrName(unicodeOrName) + const emojiSummary = [...state.currentEmojis, ...state.currentFavorites] + .find(_ => (_.id === unicodeOrName)) + const skinTonedUnicode = emojiSummary.unicode && unicodeWithSkin(emojiSummary, state.currentSkinTone) + await state.database.incrementFavoriteEmojiCount(unicodeOrName) + fireEvent('emoji-click', { + emoji, + skinTone: state.currentSkinTone, + ...(skinTonedUnicode && { unicode: skinTonedUnicode }), + ...(emojiSummary.name && { name: emojiSummary.name }) + }) } - halt(event) - const id = target.id.substring(4) // replace 'emo-' or 'fav-' prefix - /* no await */ clickEmoji(id) -} + async function onEmojiClick (event) { + const { target } = event + if (!target.classList.contains('emoji')) { + return + } + halt(event) + const id = target.id.substring(4) // replace 'emo-' or 'fav-' prefix + + /* no await */ clickEmoji(id) + } // // Handle user input on the skintone picker // -// eslint-disable-next-line no-unused-vars -function changeSkinTone (skinTone) { - currentSkinTone = skinTone - skinTonePickerExpanded = false - focus('skintone-button') - fireEvent('skin-tone-change', { skinTone }) - /* no await */ database.setPreferredSkinTone(skinTone) -} - -// eslint-disable-next-line no-unused-vars -function onSkinToneOptionsClick (event) { - const { target: { id } } = event - const match = id && id.match(/^skintone-(\d)/) // skintone option format - if (!match) { // not a skintone option - return + function changeSkinTone (skinTone) { + state.currentSkinTone = skinTone + state.skinTonePickerExpanded = false + focus('skintone-button') + fireEvent('skin-tone-change', { skinTone }) + /* no await */ database.setPreferredSkinTone(skinTone) } - halt(event) - const skinTone = parseInt(match[1], 10) // remove 'skintone-' prefix - changeSkinTone(skinTone) -} -// eslint-disable-next-line no-unused-vars -function onClickSkinToneButton (event) { - skinTonePickerExpanded = !skinTonePickerExpanded - activeSkinTone = currentSkinTone - if (skinTonePickerExpanded) { + function onSkinToneOptionsClick (event) { + const { target: { id } } = event + const match = id && id.match(/^skintone-(\d)/) // skintone option format + if (!match) { // not a skintone option + return + } halt(event) - requestAnimationFrame(() => focus('skintone-list')) + const skinTone = parseInt(match[1], 10) // remove 'skintone-' prefix + changeSkinTone(skinTone) + } + + function onClickSkinToneButton (event) { + state.skinTonePickerExpanded = !state.skinTonePickerExpanded + state.activeSkinTone = state.currentSkinTone + if (state.skinTonePickerExpanded) { + halt(event) + requestAnimationFrame(() => focus('skintone-list')) + } } -} // To make the animation nicer, change the z-index of the skintone picker button // *after* the animation has played. This makes it appear that the picker box // is expanding "below" the button -$: { - if (skinTonePickerExpanded) { - skinToneDropdown.addEventListener('transitionend', () => { - skinTonePickerExpandedAfterAnimation = true // eslint-disable-line no-unused-vars - }, { once: true }) - } else { - skinTonePickerExpandedAfterAnimation = false // eslint-disable-line no-unused-vars - } -} - -// eslint-disable-next-line no-unused-vars -function onSkinToneOptionsKeydown (event) { - if (!skinTonePickerExpanded) { - return - } + createEffect(() => { + if (state.skinTonePickerExpanded) { + state.skinToneDropdown.addEventListener('transitionend', () => { + state.skinTonePickerExpandedAfterAnimation = true // eslint-disable-line no-unused-vars + }, { once: true }) + } else { + state.skinTonePickerExpandedAfterAnimation = false // eslint-disable-line no-unused-vars + } + }) - const changeActiveSkinTone = async nextSkinTone => { - halt(event) - activeSkinTone = nextSkinTone - } + function onSkinToneOptionsKeydown (event) { + if (!state.skinTonePickerExpanded) { + return + } - switch (event.key) { - case 'ArrowUp': - return changeActiveSkinTone(incrementOrDecrement(true, activeSkinTone, skinTones)) - case 'ArrowDown': - return changeActiveSkinTone(incrementOrDecrement(false, activeSkinTone, skinTones)) - case 'Home': - return changeActiveSkinTone(0) - case 'End': - return changeActiveSkinTone(skinTones.length - 1) - case 'Enter': - // enter on keydown, space on keyup. this is just how browsers work for buttons - // https://lists.w3.org/Archives/Public/w3c-wai-ig/2019JanMar/0086.html + const changeActiveSkinTone = async nextSkinTone => { halt(event) - return changeSkinTone(activeSkinTone) - case 'Escape': - halt(event) - skinTonePickerExpanded = false - return focus('skintone-button') - } -} + state.activeSkinTone = nextSkinTone + } -// eslint-disable-next-line no-unused-vars -function onSkinToneOptionsKeyup (event) { - if (!skinTonePickerExpanded) { - return + switch (event.key) { + case 'ArrowUp': + return changeActiveSkinTone(incrementOrDecrement(true, state.activeSkinTone, state.skinTones)) + case 'ArrowDown': + return changeActiveSkinTone(incrementOrDecrement(false, state.activeSkinTone, state.skinTones)) + case 'Home': + return changeActiveSkinTone(0) + case 'End': + return changeActiveSkinTone(state.skinTones.length - 1) + case 'Enter': + // enter on keydown, space on keyup. this is just how browsers work for buttons + // https://lists.w3.org/Archives/Public/w3c-wai-ig/2019JanMar/0086.html + halt(event) + return changeSkinTone(state.activeSkinTone) + case 'Escape': + halt(event) + state.skinTonePickerExpanded = false + return focus('skintone-button') + } } - switch (event.key) { - case ' ': - // enter on keydown, space on keyup. this is just how browsers work for buttons - // https://lists.w3.org/Archives/Public/w3c-wai-ig/2019JanMar/0086.html - halt(event) - return changeSkinTone(activeSkinTone) + + function onSkinToneOptionsKeyup (event) { + if (!state.skinTonePickerExpanded) { + return + } + switch (event.key) { + case ' ': + // enter on keydown, space on keyup. this is just how browsers work for buttons + // https://lists.w3.org/Archives/Public/w3c-wai-ig/2019JanMar/0086.html + halt(event) + return changeSkinTone(state.activeSkinTone) + } } -} -// eslint-disable-next-line no-unused-vars -async function onSkinToneOptionsFocusOut (event) { - // On blur outside of the skintone listbox, collapse the skintone picker. - const { relatedTarget } = event - if (!relatedTarget || relatedTarget.id !== 'skintone-list') { - skinTonePickerExpanded = false + async function onSkinToneOptionsFocusOut (event) { + // On blur outside of the skintone listbox, collapse the skintone picker. + const { relatedTarget } = event + if (!relatedTarget || relatedTarget.id !== 'skintone-list') { + state.skinTonePickerExpanded = false + } } -} + +} \ No newline at end of file From a21771e08d07facfbba58ecc3e3193796da5bf74 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sat, 18 Nov 2023 15:23:54 -0800 Subject: [PATCH 016/113] fix: move to tagged template literals --- src/picker/components/Picker/Picker.html | 190 -------------- src/picker/components/Picker/Picker.js | 9 +- .../components/Picker/PickerTemplate.js | 238 ++++++++++++++++++ 3 files changed, 241 insertions(+), 196 deletions(-) delete mode 100644 src/picker/components/Picker/Picker.html create mode 100644 src/picker/components/Picker/PickerTemplate.js diff --git a/src/picker/components/Picker/Picker.html b/src/picker/components/Picker/Picker.html deleted file mode 100644 index da6efa55..00000000 --- a/src/picker/components/Picker/Picker.html +++ /dev/null @@ -1,190 +0,0 @@ -{#snippet emojiList(args)} - {@const emojis = args[0]} - {@const searchMode = args[1]} - {@const prefix = args[2]} - {#each emojis as emoji, i (emoji.id)} - - {/each} -{/snippet} - -
- -
-
-
- - - - {i18n.searchDescription} -
- -
- -
- {i18n.skinToneDescription} -
- {#each skinTones as skinTone, i (skinTone)} -
- {skinTone} -
- {/each} -
- -
- - - -
-
-
-
- - - - - - - -
-
- {#each currentEmojisWithCategories as emojiWithCategory, i (emojiWithCategory.category)} - -
- {@render emojiList([emojiWithCategory.emojis, searchMode, 'emo'])} -
- {/each} -
-
- - - - - - -
\ No newline at end of file diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index aa73eaca..6b608dda 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -90,13 +90,11 @@ export function createRoot(target, props) { })) } -// eslint-disable-next-line no-unused-vars - const unicodeWithSkin = (emoji, currentSkinTone) => ( + export const unicodeWithSkin = (emoji, currentSkinTone) => ( (currentSkinTone && emoji.skins && emoji.skins[currentSkinTone]) || emoji.unicode ) -// eslint-disable-next-line no-unused-vars - const labelWithSkin = (emoji, currentSkinTone) => ( + export const labelWithSkin = (emoji, currentSkinTone) => ( uniq([ (emoji.name || unicodeWithSkin(emoji, currentSkinTone)), emoji.annotation, @@ -104,8 +102,7 @@ export function createRoot(target, props) { ].filter(Boolean)).join(', ') ) -// eslint-disable-next-line no-unused-vars - const titleForEmoji = (emoji) => ( + export const titleForEmoji = (emoji) => ( emoji.annotation || (emoji.shortcodes || EMPTY_ARRAY).join(', ') ) diff --git a/src/picker/components/Picker/PickerTemplate.js b/src/picker/components/Picker/PickerTemplate.js new file mode 100644 index 00000000..af884c5e --- /dev/null +++ b/src/picker/components/Picker/PickerTemplate.js @@ -0,0 +1,238 @@ +import { labelWithSkin, titleForEmoji, unicodeWithSkin } from './Picker.js' + +export function root(state) { + +function emojiList(emojis, searchMode, prefix) { + return map(emojis, (emoji, i) => { + return html` + + ` + }) +} + +function skintoneButtons() { + return map(state.skinTones, (skinTone, i) => { + return html` +
+ ${skinTone} +
+ ` + }) +} + +function skintonePicker() { + // For the pattern used for the skintone dropdown, see: + // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/ + // The one case where we deviate from the example is that we move focus from the button to the + // listbox. (The example uses a combobox, so it's not exactly the same.) This was tested in NVDA and VoiceOver. + return html` +
+ +
+ ${state.i18n.skinToneDescription} +
+ ${skintoneButtons()} +
+ ` +} + +function searchBox() { + return html` +
+
+ + + + ${state.i18n.searchDescription} +
+
+ ` +} + +function emojiTabs() { + return map(currentEmojisWithCategories, (emojiWithCategory, i) => { + return html` + +
+ ${emojiList(emojiWithCategory.emojis, state.searchMode, 'emo')} +
+ ` + }) +} + +function emojiTabPanel() { + // + // + // + // + return html` +
+
+ ${emojiTabs()} +
+
+ ` +} + +function navButtons() { + return map(state.groups, (group) => { + return html` + + ` + }) +} + +function nav() { + // this is interactive because of keydown; it doesn't really need focus + return html` + + ` +} + +function section() { + return html` +
+ +
+ ${searchBox()} + ${skintonePicker()} + ${nav()} +
+
+
+
+ + + + ${emojiTabPanel()} + + + + + + +
+ ` +} + + return section() + +} \ No newline at end of file From 498a0f8926fee098a32a8c293360ebf6a606715a Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sat, 18 Nov 2023 15:31:02 -0800 Subject: [PATCH 017/113] fix: do bind:this --- src/picker/components/Picker/Picker.js | 26 +++++++++++------- .../components/Picker/PickerTemplate.js | 27 ++++++++++--------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index 6b608dda..e980ebfc 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -20,6 +20,7 @@ import { requestPostAnimationFrame } from '../../utils/requestPostAnimationFrame import { requestAnimationFrame } from '../../utils/requestAnimationFrame' import { uniq } from '../../../shared/uniq' import { resetScrollTopIfPossible } from '../../utils/resetScrollTopIfPossible.js' +import { root } from './PickerTemplate.js' // constants const EMPTY_ARRAY = [] @@ -47,7 +48,7 @@ export function createRoot(target, props) { currentEmojisWithCategories: [], rawSearchText: '', searchText: '', - rootElement: undefined, + dom.rootElement: undefined, baselineEmoji: undefined, tabpanelElement: undefined, searchMode: false, @@ -72,29 +73,31 @@ export function createRoot(target, props) { databaseLoaded: false, activeSearchItemId: undefined, }) - + // // Utils/helpers // const focus = id => { - rootElement.getRootNode().getElementById(id).focus() + dom.rootElement.getRootNode().getElementById(id).focus() } // fire a custom event that crosses the shadow boundary const fireEvent = (name, detail) => { - rootElement.dispatchEvent(new CustomEvent(name, { + dom.rootElement.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })) } - export const unicodeWithSkin = (emoji, currentSkinTone) => ( + // Helpers + + const unicodeWithSkin = (emoji, currentSkinTone) => ( (currentSkinTone && emoji.skins && emoji.skins[currentSkinTone]) || emoji.unicode ) - export const labelWithSkin = (emoji, currentSkinTone) => ( + const labelWithSkin = (emoji, currentSkinTone) => ( uniq([ (emoji.name || unicodeWithSkin(emoji, currentSkinTone)), emoji.annotation, @@ -102,10 +105,15 @@ export function createRoot(target, props) { ].filter(Boolean)).join(', ') ) - export const titleForEmoji = (emoji) => ( + const titleForEmoji = (emoji) => ( emoji.annotation || (emoji.shortcodes || EMPTY_ARRAY).join(', ') ) + const helpers = { + labelWithSkin, titleForEmoji, unicodeWithSkin + } + const dom = root(state, helpers) + // // Determine the emoji support level (in requestIdleCallback) // @@ -266,7 +274,7 @@ createEffect(() => { /* istanbul ignore next */ if (process.env.NODE_ENV !== 'test') { // jsdom throws errors for this kind of fancy stuff // read all the style/layout calculations we need to make - const style = getComputedStyle(rootElement) + const style = getComputedStyle(dom.rootElement) const newNumColumns = parseInt(style.getPropertyValue('--num-columns'), 10) const newIsRtl = style.getPropertyValue('direction') === 'rtl' const parentWidth = node.parentElement.getBoundingClientRect().width @@ -337,7 +345,7 @@ createEffect(() => { }) function checkZwjSupportAndUpdate (zwjEmojisToCheck) { - const rootNode = rootElement.getRootNode() + const rootNode = dom.rootElement.getRootNode() const emojiToDomNode = emoji => rootNode.getElementById(`emo-${emoji.id}`) checkZwjSupport(zwjEmojisToCheck, baselineEmoji, emojiToDomNode) // force update diff --git a/src/picker/components/Picker/PickerTemplate.js b/src/picker/components/Picker/PickerTemplate.js index af884c5e..0386a72a 100644 --- a/src/picker/components/Picker/PickerTemplate.js +++ b/src/picker/components/Picker/PickerTemplate.js @@ -1,12 +1,12 @@ -import { labelWithSkin, titleForEmoji, unicodeWithSkin } from './Picker.js' +export function root(state, helpers) { -export function root(state) { + const { labelWithSkin, titleForEmoji, unicodeWithSkin } = helpers function emojiList(emojis, searchMode, prefix) { return map(emojis, (emoji, i) => { return html` +
` } - return section() + const dom = section() + return { + rootElement: dom.querySelector('.picker'), + skinToneDropdown: dom.querySelector('#skintone-list'), + tabpanelElement: dom.querySelector('.tabpanel'), + baselineEmoji: dom.querySelector('.baseline-emoji') + } } \ No newline at end of file From ab18d6923ee993e9c74444544f199a4d484ea27f Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sat, 18 Nov 2023 15:36:52 -0800 Subject: [PATCH 018/113] fix: finish up dom bindings --- src/picker/components/Picker/Picker.js | 17 +++++++------- .../components/Picker/PickerTemplate.js | 22 +++++++++---------- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index e980ebfc..c97c03c9 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -48,15 +48,11 @@ export function createRoot(target, props) { currentEmojisWithCategories: [], rawSearchText: '', searchText: '', - dom.rootElement: undefined, - baselineEmoji: undefined, - tabpanelElement: undefined, searchMode: false, activeSearchItem: -1, message: undefined, skinTonePickerExpanded: false, skinTonePickerExpandedAfterAnimation: false, - skinToneDropdown: undefined, currentSkinTone: 0, activeSkinTone: 0, skinToneButtonText: undefined, @@ -73,6 +69,8 @@ export function createRoot(target, props) { databaseLoaded: false, activeSearchItemId: undefined, }) + + state.currentGroup = state.groups[state.currentGroupIndex] // // Utils/helpers @@ -112,7 +110,7 @@ export function createRoot(target, props) { const helpers = { labelWithSkin, titleForEmoji, unicodeWithSkin } - const dom = root(state, helpers) + const { dom } = root(state, helpers) // // Determine the emoji support level (in requestIdleCallback) @@ -340,16 +338,17 @@ createEffect(() => { } else { state.currentEmojis = state.emojiVersion ? state.currentEmojis : state.currentEmojis.filter(isZwjSupported) // Reset scroll top to 0 when emojis change - requestAnimationFrame(() => resetScrollTopIfPossible(tabpanelElement)) + requestAnimationFrame(() => resetScrollTopIfPossible(dom.tabpanelElement)) } }) function checkZwjSupportAndUpdate (zwjEmojisToCheck) { const rootNode = dom.rootElement.getRootNode() const emojiToDomNode = emoji => rootNode.getElementById(`emo-${emoji.id}`) - checkZwjSupport(zwjEmojisToCheck, baselineEmoji, emojiToDomNode) + checkZwjSupport(zwjEmojisToCheck, dom.baselineEmoji, emojiToDomNode) // force update - state.currentEmojis = currentEmojis // eslint-disable-line no-self-assign + const { currentEmojis } = state + state.currentEmojis = currentEmojis } function isZwjSupported (emoji) { @@ -565,7 +564,7 @@ createEffect(() => { // is expanding "below" the button createEffect(() => { if (state.skinTonePickerExpanded) { - state.skinToneDropdown.addEventListener('transitionend', () => { + dom.skinToneDropdown.addEventListener('transitionend', () => { state.skinTonePickerExpandedAfterAnimation = true // eslint-disable-line no-unused-vars }, { once: true }) } else { diff --git a/src/picker/components/Picker/PickerTemplate.js b/src/picker/components/Picker/PickerTemplate.js index 0386a72a..1a831adc 100644 --- a/src/picker/components/Picker/PickerTemplate.js +++ b/src/picker/components/Picker/PickerTemplate.js @@ -139,11 +139,9 @@ function emojiTabs() { } function emojiTabPanel() { - // - // - // - // + // The tabindex=0 is so people can scroll up and down with the keyboard. The element has a role and a label, so I + // feel it's appropriate to have the tabindex. + // This on:click is a delegated click listener return html`
- - ${state.i18n.skinToneDescription} -
+ data-on-focusout="onSkinToneOptionsFocusOut" + data-on-click="onSkinToneOptionsClick" + data-on-keydown="onSkinToneOptionsKeydown" + data-on-keyup="onSkinToneOptionsKeyup"> ${skintoneButtons()}
` @@ -95,8 +97,9 @@ function searchBox() { aria-describedby="search-description" aria-autocomplete="list" aria-activedescendant="${state.activeSearchItemId ? `emo-${state.activeSearchItemId}` : ''}" - bind:value={rawSearchText} - on:keydown={onSearchKeydown} + data-ref="searchElement" + data-on-input="onSearchInput" + data-on-keydown="onSearchKeydown" > ${state.i18n.searchDescription} @@ -143,12 +146,12 @@ function emojiTabPanel() { // feel it's appropriate to have the tabindex. // This on:click is a delegated click listener return html` -
${emojiTabs()} @@ -166,7 +169,7 @@ function navButtons() { aria-label="${i18n.categories[group.name]}" aria-selected="${!state.searchMode && state.currentGroup.id === group.id}" title="${state.i18n.categories[group.name]}" - on:click={() => onNavClick(group)}> + data-on-click="() => onNavClick(group)"> @@ -182,7 +185,7 @@ function nav() { role="tablist" style="grid-template-columns: repeat(${state.groups.length}, 1fr)" aria-label="${i18n.categoriesLabel}" - on:keydown={onNavKeydown}> + data-on-keydown="onNavKeydown"> ${navButtons()}
` @@ -191,6 +194,7 @@ function nav() { function section() { return html`
@@ -217,23 +221,33 @@ function section() { role="menu" aria-label="${state.i18n.favoritesLabel}" style="padding-inline-end: ${state.scrollbarWidth}px" - on:click={onEmojiClick}> + data-on-click="onEmojiClick"> ${emojiList(state.currentFavorites, false, 'fav')}
- +
` } const rootDom = section() - return { - dom: { - rootElement: rootDom.querySelector('.picker'), - skinToneDropdown: rootDom.querySelector('#skintone-list'), - tabpanelElement: rootDom.querySelector('.tabpanel'), - baselineEmoji: rootDom.querySelector('.baseline-emoji') + // bind events + for (const eventName of ['click', 'focusout', 'input', 'keydown', 'keyup']) { + for (const element of rootDom.querySelectorAll(`[data-on-${eventName}]`)) { + const listenerName = element.getAttribute(`data-on-${eventName}`) + element.addEventListener(eventName, events[listenerName]) } } + + // find refs + const refs = {} + for (const element of rootDom.querySelectorAll('[data-ref]')) { + const { ref } = element.dataset + refs[ref] = element + } + + return { + refs + } } \ No newline at end of file diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js new file mode 100644 index 00000000..a02ab2c5 --- /dev/null +++ b/src/picker/components/Picker/framework.js @@ -0,0 +1,3 @@ +export function html() { + +} \ No newline at end of file From 235470e86ceaff44fe62bb4c60d85b7db7402dba Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sun, 19 Nov 2023 14:45:52 -0800 Subject: [PATCH 020/113] fix: beginning of framework --- src/picker/components/Picker/framework.js | 179 +++++++++++++++++++++- 1 file changed, 178 insertions(+), 1 deletion(-) diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index a02ab2c5..7d4bcd82 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -1,3 +1,180 @@ -export function html() { +// via https://github.com/component/escape-html/blob/b42947eefa79efff01b3fe988c4c7e7b051ec8d8/index.js +function escapeHtml(string) { + let str = '' + string + const match = /["'&<>]/.exec(str) + if (!match) { + return str + } + + let escape + let html = '' + let index = 0 + let lastIndex = 0 + + for (index = match.index; index < str.length; index++) { + switch (str.charCodeAt(index)) { + case 34: // " + escape = '"' + break + case 38: // & + escape = '&' + break + case 39: // ' + escape = ''' + break + case 60: // < + escape = '<' + break + case 62: // > + escape = '>' + break + default: + continue + } + + if (lastIndex !== index) { + html += str.substring(lastIndex, index) + } + + lastIndex = index + 1 + html += escape + } + + return lastIndex !== index ? html + str.substring(lastIndex, index) : html +} + +function toString(value) { + if (typeof value === 'undefined') { + return 'undefined' + } else if (value === null) { + return 'null' + } + return value.toString() +} + +function parseDom(htmlString) { + const template = document.createElement('template') + template.innerHTML = htmlString + return template.content.firstChild +} + +function parse(tokens) { + let htmlString = '' + + let withinTag = false + let withinAttribute = false + let elementIndex = -1 // depth-first traversal order + + const boundExpressions = new Map() + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i] + htmlString += token + + if (i === tokens.length - 1) { + break // no need to process characters + } + + for (const char of token) { + switch (char) { + case '<': + withinTag = true + elementIndex++ + break + case '>': + withinTag = false + withinAttribute = false + break + case '=': + if (withinTag) { + withinAttribute = true + } + break + } + } + + let bindings = boundExpressions.get(elementIndex) + if (!bindings) { + bindings = [] + boundExpressions.set(elementIndex, bindings) + } + + let attributeName + let attributeValuePre + let attributeValuePost + if (withinAttribute) { + attributeName = /(\S+)="?(?:[^"]+)?$/.exec(token)[1] + attributeValuePre = /="?([^"=]*)$/.exec(token)[1] + attributeValuePost = /^([^">]*)/.exec(tokens[i + 1])[1] + } + + bindings.push({ + withinTag, + withinAttribute, + attributeName, + attributeValuePre, + attributeValuePost, + expressionIndex: i + }) + + + htmlString += (!withinTag && !withinAttribute) ? `` : '' + } + + const dom = parseDom(htmlString) + + return { + dom, + boundExpressions + } +} + +export function html(tokens, ...expressions) { + + const { + dom, + boundExpressions + } = parse(tokens, expressions) + + // traverse dom + const treeWalker = document.createTreeWalker(dom, NodeFilter.SHOW_ELEMENT) + + let element = dom + let elementIndex = -1 + do { + const bindings = boundExpressions.get(++elementIndex) + if (bindings) { + let foundComments + for (let i = 0; i < bindings.length; i++) { + const binding = bindings[i] + const { expressionIndex } = binding + const escapedExpressionValue = escapeHtml(toString(expressions[expressionIndex])) + if (binding.withinAttribute) { + const { attributeName, attributeValuePre, attributeValuePost } = binding + element.setAttribute(attributeName, attributeValuePre + escapedExpressionValue + attributeValuePost) + } else { // text content - replace comments + if (!foundComments) { + // find all comments once + foundComments = new Map() + for (const childNode of element.childNodes) { + let match + if (childNode.nodeType === Node.COMMENT_NODE && (match = /^placeholder-(\d+)/.exec(childNode.textContent))) { + foundComments.set(parseInt(match[1], 10), childNode) + } + } + } + const comment = foundComments.get(i) + const textNode = document.createTextNode(toString(expressions[expressionIndex])) + binding.textNode = textNode + comment.replaceWith(textNode) + } + binding.element = element + } + } + } while ((element = treeWalker.nextNode())) + + return { + dom, boundExpressions + } } \ No newline at end of file From 7ce1a5dd50fc8971fce7fee0e717e05619a19682 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sun, 19 Nov 2023 14:56:30 -0800 Subject: [PATCH 021/113] fix: nodes within nodes --- src/picker/components/Picker/framework.js | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index 7d4bcd82..aa38c04c 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -130,6 +130,8 @@ function parse(tokens) { } } +const isHtmlTagTemplateExpression = Symbol('html-tag-template-expression') + export function html(tokens, ...expressions) { const { @@ -149,7 +151,8 @@ export function html(tokens, ...expressions) { for (let i = 0; i < bindings.length; i++) { const binding = bindings[i] const { expressionIndex } = binding - const escapedExpressionValue = escapeHtml(toString(expressions[expressionIndex])) + const expression = expressions[expressionIndex] + const escapedExpressionValue = escapeHtml(toString(expression)) if (binding.withinAttribute) { const { attributeName, attributeValuePre, attributeValuePost } = binding element.setAttribute(attributeName, attributeValuePre + escapedExpressionValue + attributeValuePost) @@ -165,9 +168,15 @@ export function html(tokens, ...expressions) { } } const comment = foundComments.get(i) - const textNode = document.createTextNode(toString(expressions[expressionIndex])) - binding.textNode = textNode - comment.replaceWith(textNode) + + let targetNode + if (expression && expression[isHtmlTagTemplateExpression]) { + targetNode = expression.dom + } else { // primitive - string, number, etc + targetNode = document.createTextNode(toString(expression)) + } + binding.targetNode = targetNode + comment.replaceWith(targetNode) } binding.element = element } @@ -175,6 +184,8 @@ export function html(tokens, ...expressions) { } while ((element = treeWalker.nextNode())) return { - dom, boundExpressions + dom, + boundExpressions, + [isHtmlTagTemplateExpression]: true } } \ No newline at end of file From f1b36fb3e983afb7d80204dde82a53f449912155 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sun, 19 Nov 2023 15:23:23 -0800 Subject: [PATCH 022/113] fix: more progress on framework --- src/picker/components/Picker/framework.js | 89 +++++++++++++++++------ 1 file changed, 65 insertions(+), 24 deletions(-) diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index aa38c04c..5570155a 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -124,21 +124,65 @@ function parse(tokens) { const dom = parseDom(htmlString) + traverseAndSetupBindings(tokens, dom, boundExpressions) + + const update = (expressions) => { + for (const bindings of boundExpressions.values()) { + for (const binding of bindings) { + const { expressionIndex, withinAttribute, targetNode, element, attributeName, attributeValuePre, attributeValuePost, lastExpression } = binding + const expression = expressions[expressionIndex] + + if (lastExpression === expression) { + // no need to update, same as before + continue + } + + binding.lastExpression = expression + + if (withinAttribute) { + element.setAttribute(attributeName, attributeValuePre + escapeHtml(toString(expression)) + attributeValuePost) + } else { // text node / dom node replacement + let newNode + if (expression && expression[isHtmlTagTemplateExpression]) { // html tag template itself + newNode = expression.dom + } else { // primitive - string, number, etc + if (targetNode.nodeType === Node.TEXT_NODE) { // already transformed into a text node + targetNode.nodeValue = toString(expression) + } else { // replace comment or whatever was there before with a text node + newNode = document.createTextNode(toString(expression)) + targetNode.replaceWith(newNode) + } + } + if (newNode) { + targetNode.replaceWith(newNode) + binding.targetNode = newNode + } + } + } + } + } + return { dom, - boundExpressions + boundExpressions, + update } } const isHtmlTagTemplateExpression = Symbol('html-tag-template-expression') -export function html(tokens, ...expressions) { +const parseCache = new WeakMap() - const { - dom, - boundExpressions - } = parse(tokens, expressions) +function parseWithCache(tokens) { + let cached = parseCache.get(tokens) + if (!cached) { + cached = parse(tokens) + parseCache.set(tokens, cached) + } + return cached +} +function traverseAndSetupBindings (tokens, dom, boundExpressions) { // traverse dom const treeWalker = document.createTreeWalker(dom, NodeFilter.SHOW_ELEMENT) @@ -150,13 +194,10 @@ export function html(tokens, ...expressions) { let foundComments for (let i = 0; i < bindings.length; i++) { const binding = bindings[i] - const { expressionIndex } = binding - const expression = expressions[expressionIndex] - const escapedExpressionValue = escapeHtml(toString(expression)) - if (binding.withinAttribute) { - const { attributeName, attributeValuePre, attributeValuePost } = binding - element.setAttribute(attributeName, attributeValuePre + escapedExpressionValue + attributeValuePost) - } else { // text content - replace comments + + binding.element = element + + if (!binding.withinAttribute) { if (!foundComments) { // find all comments once foundComments = new Map() @@ -167,21 +208,21 @@ export function html(tokens, ...expressions) { } } } - const comment = foundComments.get(i) - - let targetNode - if (expression && expression[isHtmlTagTemplateExpression]) { - targetNode = expression.dom - } else { // primitive - string, number, etc - targetNode = document.createTextNode(toString(expression)) - } - binding.targetNode = targetNode - comment.replaceWith(targetNode) + binding.targetNode = foundComments.get(i) } - binding.element = element } } } while ((element = treeWalker.nextNode())) +} + +export function html(tokens, ...expressions) { + const { + dom, + boundExpressions, + update + } = parseWithCache(tokens) + + update(expressions) return { dom, From 2bc49097e48fbf001f46b7775daa900acf3707e3 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sun, 19 Nov 2023 16:04:26 -0800 Subject: [PATCH 023/113] fix: progress --- src/picker/PickerElement.js | 6 - src/picker/components/Picker/Picker.js | 54 ++- src/picker/components/Picker/Picker.svelte | 2 - .../components/Picker/PickerTemplate.js | 424 +++++++++--------- src/picker/components/Picker/framework.js | 4 + src/picker/components/Picker/reactivity.js | 60 +++ 6 files changed, 315 insertions(+), 235 deletions(-) delete mode 100644 src/picker/components/Picker/Picker.svelte create mode 100644 src/picker/components/Picker/reactivity.js diff --git a/src/picker/PickerElement.js b/src/picker/PickerElement.js index 8cd58da7..3257deed 100644 --- a/src/picker/PickerElement.js +++ b/src/picker/PickerElement.js @@ -67,12 +67,6 @@ export default class PickerElement extends HTMLElement { if (!this.isConnected && this._cmp) { this._cmp.$destroy() this._cmp = undefined - const picker = this.shadowRoot.querySelector('.picker') - // This is never undefined, but I'd feel more comfortable with the `if` anyway - /* istanbul ignore else */ - if (picker) { - picker.remove() - } const { database } = this._ctx database.close() diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index f6bd19e2..65615970 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -20,7 +20,7 @@ import { requestPostAnimationFrame } from '../../utils/requestPostAnimationFrame import { requestAnimationFrame } from '../../utils/requestAnimationFrame' import { uniq } from '../../../shared/uniq' import { resetScrollTopIfPossible } from '../../utils/resetScrollTopIfPossible.js' -import { root } from './PickerTemplate.js' +import { createRootDom } from './PickerTemplate.js' // constants const EMPTY_ARRAY = [] @@ -28,20 +28,23 @@ const EMPTY_ARRAY = [] const { assign } = Object export function createRoot(target, props) { + const { state, createEffect } = createState() + const destroyCallbacks = [] - const state = { + // initial state + assign(state, { skinToneEmoji: undefined, i18n: undefined, database: undefined, customEmoji: undefined, customCategorySorting: undefined, emojiVersion: undefined, - } + }) - // public + // public props assign(state, props) - // private + // private props assign(state, { initialLoad: true, currentEmojis: [], @@ -70,19 +73,24 @@ export function createRoot(target, props) { activeSearchItemId: undefined, }) - state.currentGroup = state.groups[state.currentGroupIndex] +// +// Update the current group based on the currentGroupIndex +// + createEffect(() => { + state.currentGroup = state.groups[state.currentGroupIndex] + }) // // Utils/helpers // const focus = id => { - refs.rootElement.getRootNode().getElementById(id).focus() + rootNode.getRootNode().getElementById(id).focus() } // fire a custom event that crosses the shadow boundary const fireEvent = (name, detail) => { - refs.rootElement.dispatchEvent(new CustomEvent(name, { + rootNode.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true @@ -121,7 +129,11 @@ export function createRoot(target, props) { onSkinToneOptionsKeydown, onSkinToneOptionsKeyup } - const { refs } = root(state, helpers, events) + + const { + refs, + rootNode + } = createRootDom(state, helpers, events, createEffect) // // Determine the emoji support level (in requestIdleCallback) @@ -283,7 +295,7 @@ createEffect(() => { /* istanbul ignore next */ if (process.env.NODE_ENV !== 'test') { // jsdom throws errors for this kind of fancy stuff // read all the style/layout calculations we need to make - const style = getComputedStyle(refs.rootElement) + const style = getComputedStyle(rootNode) const newNumColumns = parseInt(style.getPropertyValue('--num-columns'), 10) const newIsRtl = style.getPropertyValue('direction') === 'rtl' const parentWidth = node.parentElement.getBoundingClientRect().width @@ -297,13 +309,11 @@ createEffect(() => { }) } -// -// Update the current group based on the currentGroupIndex -// + // calculate the width and clean up on destroy + const calculator = calculateEmojiGridStyle(refs.emojiGrid) + + destroyCallbacks.push(calculator.destroy) - createEffect(() => { - state.currentGroup = state.groups[state.currentGroupIndex] - }) // // Set or update the currentEmojis. Check for invalid ZWJ renderings @@ -354,7 +364,7 @@ createEffect(() => { }) function checkZwjSupportAndUpdate (zwjEmojisToCheck) { - const rootNode = refs.rootElement.getRootNode() + const rootNode = rootNode.getRootNode() const emojiToDomNode = emoji => rootNode.getElementById(`emo-${emoji.id}`) checkZwjSupport(zwjEmojisToCheck, refs.baselineEmoji, emojiToDomNode) // force update @@ -640,4 +650,14 @@ createEffect(() => { state.rawSearchText = event.target.value } + target.appendChild(rootNode) + + return { + destroy () { + rootNode.remove() + for (const destroyCallback of destroyCallbacks) { + destroyCallback() + } + } + } } \ No newline at end of file diff --git a/src/picker/components/Picker/Picker.svelte b/src/picker/components/Picker/Picker.svelte deleted file mode 100644 index 6f911d9c..00000000 --- a/src/picker/components/Picker/Picker.svelte +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/src/picker/components/Picker/PickerTemplate.js b/src/picker/components/Picker/PickerTemplate.js index 330679e9..18ccf1b1 100644 --- a/src/picker/components/Picker/PickerTemplate.js +++ b/src/picker/components/Picker/PickerTemplate.js @@ -1,234 +1,237 @@ -export function root(state, helpers, events) { +import { html, map } from './framework.js' + +export function createRootDom (state, helpers, events, createEffect) { const { labelWithSkin, titleForEmoji, unicodeWithSkin } = helpers -function emojiList(emojis, searchMode, prefix) { - return map(emojis, (emoji, i) => { + const emojiList = createEffect((emojis, searchMode, prefix) => { + return map(emojis, (emoji, i) => { + return html` + + ` + }) + }) + + const skintoneButtons = createEffect(() => { + return map(state.skinTones, (skinTone, i) => { + return html` +
+ ${skinTone} +
+ ` + }) + }) + + const skintonePicker = createEffect(() => { + // For the pattern used for the skintone dropdown, see: + // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/ + // The one case where we deviate from the example is that we move focus from the button to the + // listbox. (The example uses a combobox, so it's not exactly the same.) This was tested in NVDA and VoiceOver. return html` - +
+ +
+ ${state.i18n.skinToneDescription} +
+ ${skintoneButtons()} +
` }) -} -function skintoneButtons() { - return map(state.skinTones, (skinTone, i) => { + const searchBox = createEffect(() => { return html` -
- ${skinTone} +
+
+ + + + ${state.i18n.searchDescription} +
` }) -} - -function skintonePicker() { - // For the pattern used for the skintone dropdown, see: - // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/ - // The one case where we deviate from the example is that we move focus from the button to the - // listbox. (The example uses a combobox, so it's not exactly the same.) This was tested in NVDA and VoiceOver. - return html` -
- -
- ${state.i18n.skinToneDescription} -
- ${skintoneButtons()} -
- ` -} - -function searchBox() { - return html` -
-
- - - - ${state.i18n.searchDescription} -
-
- ` -} -function emojiTabs() { - return map(currentEmojisWithCategories, (emojiWithCategory, i) => { + const emojiTabs = createEffect(() => { + return map(state.currentEmojisWithCategories, (emojiWithCategory, i) => { + return html` + +
+ ${emojiList(emojiWithCategory.emojis, state.searchMode, 'emo')} +
+ ` + }) + }) + + const emojiTabPanel = createEffect(() => { + // The tabindex=0 is so people can scroll up and down with the keyboard. The element has a role and a label, so I + // feel it's appropriate to have the tabindex. + // This on:click is a delegated click listener return html` - -
- ${emojiList(emojiWithCategory.emojis, state.searchMode, 'emo')} +
+
+ ${emojiTabs()} +
` }) -} - -function emojiTabPanel() { - // The tabindex=0 is so people can scroll up and down with the keyboard. The element has a role and a label, so I - // feel it's appropriate to have the tabindex. - // This on:click is a delegated click listener - return html` -
-
- ${emojiTabs()} + + const navButtons = createEffect(() => { + return map(state.groups, (group) => { + return html` + + ` + }) + }) + + const nav = createEffect(() => { + // this is interactive because of keydown; it doesn't really need focus + return html` + -
- ` -} + ` + }) -function navButtons() { - return map(state.groups, (group) => { + const section = createEffect(() => { return html` - + + ${emojiTabPanel()} + + + + +
` }) -} - -function nav() { - // this is interactive because of keydown; it doesn't really need focus - return html` - - ` -} - -function section() { - return html` -
- -
- ${searchBox()} - ${skintonePicker()} - ${nav()} -
-
-
-
- - - - ${emojiTabPanel()} - - - - -
- ` -} const rootDom = section() @@ -248,6 +251,7 @@ function section() { } return { - refs + refs, + rootNode: rootDom } } \ No newline at end of file diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index 5570155a..abf68e55 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -229,4 +229,8 @@ export function html(tokens, ...expressions) { boundExpressions, [isHtmlTagTemplateExpression]: true } +} + +export function map(array, callback) { + return array.map(callback) } \ No newline at end of file diff --git a/src/picker/components/Picker/reactivity.js b/src/picker/components/Picker/reactivity.js new file mode 100644 index 00000000..8cadc6ba --- /dev/null +++ b/src/picker/components/Picker/reactivity.js @@ -0,0 +1,60 @@ +export function createState() { + + let currentObserver + + const propsToObservers = new Map() + const dirtyObservers = new Set() + + let queued + + const flush = () => { + try { + for (const observer of dirtyObservers) { + observer() + } + } finally { + queued = false + } + } + + const state = new Proxy({}, { + get(target, prop) { + if (currentObserver) { + let observers = propsToObservers.get(prop) + if (!observers) { + observers = new Set() + propsToObservers.set(prop, observers) + } + observers.add(currentObserver) + } + return target[prop] + }, + set(target, prop, newValue) { + target[prop] = newValue + const observers = propsToObservers.get(prop) + if (observers) { + for (const observer of observers) { + dirtyObservers.add(observer) + } + if (!queued) { + queued = true + Promise.resolve().then(flush) + } + } + } + }) + + const createEffect = (callback) => { + currentObserver = callback + try { + callback() + } finally { + currentObserver = undefined + } + } + + return { + state, + createEffect + } +} \ No newline at end of file From e53055875a3468449f9953ad79f529d0470fb0ef Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sun, 19 Nov 2023 16:10:40 -0800 Subject: [PATCH 024/113] fix: standard style --- package.json | 2 + rollup.config.js | 54 +---- src/picker/PickerElement.js | 8 +- src/picker/components/Picker/Picker.js | 207 +++++++++--------- .../components/Picker/PickerTemplate.js | 23 +- src/picker/components/Picker/framework.js | 19 +- src/picker/components/Picker/reactivity.js | 9 +- 7 files changed, 136 insertions(+), 186 deletions(-) diff --git a/package.json b/package.json index ade8844d..eb3c9a0a 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,8 @@ "Headers", "HTMLElement", "matchMedia", + "Node", + "NodeFilter", "performance", "ResizeObserver", "Response", diff --git a/rollup.config.js b/rollup.config.js index 3a58068c..80251f0e 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,10 +1,7 @@ -import MagicString from 'magic-string' -import inject from '@rollup/plugin-inject' import cjs from '@rollup/plugin-commonjs' import resolve from '@rollup/plugin-node-resolve' import replace from '@rollup/plugin-replace' import strip from '@rollup/plugin-strip' -import svelte from 'rollup-plugin-svelte' import preprocess from 'svelte-preprocess' import analyze from 'rollup-plugin-analyzer' import { buildStyles } from './bin/buildStyles.js' @@ -43,26 +40,8 @@ const baseConfig = { delimiters: ['', ''], preventAssignment: true }), - svelte({ - compilerOptions: { - dev, - discloseVersion: false - }, - preprocess: preprocessConfig, - emitCss: false - }), - // make the svelte output slightly smaller - replace({ - 'options.anchor': 'undefined', - 'options.context': 'undefined', - 'options.customElement': 'undefined', - 'options.hydrate': 'undefined', - 'options.intro': 'undefined', - delimiters: ['', ''], - preventAssignment: true - }), strip({ - include: ['**/*.js', '**/*.svelte'], + include: ['**/*.js'], functions: [ (!dev && !process.env.PERF) && 'performance.*', !dev && 'console.log' @@ -95,36 +74,9 @@ const entryPoints = [ format: 'cjs' }, { + // for backwards compat input: './src/picker/PickerElement.js', - output: './svelte.js', - external: ['svelte', 'svelte/internal'], - // TODO: drop Svelte v3 support - // ensure_array_like was added in Svelte v4 - we shim it to avoid breaking Svelte v3 users - plugins: [ - { - name: 'svelte-v3-compat', - transform (source) { - const magicString = new MagicString(source) - magicString.replaceAll('ensure_array_like(', 'ensure_array_like_shim(') - - return { - code: magicString.toString(), - map: magicString.generateMap() - } - } - }, - inject({ - ensure_array_like_shim: [ - '../../../../shims/svelte-v3-shim.js', - 'ensure_array_like_shim' - ] - }) - ], - onwarn (warning) { - if (!warning.message.includes('ensure_array_like')) { // intentionally ignore warning for unused import - console.warn(warning.message) - } - } + output: './svelte.js' } ] diff --git a/src/picker/PickerElement.js b/src/picker/PickerElement.js index 3257deed..dd2feec0 100644 --- a/src/picker/PickerElement.js +++ b/src/picker/PickerElement.js @@ -1,5 +1,4 @@ -import { createRoot } from 'svelte' -import SveltePicker from './components/Picker/Picker.svelte' +import { createRoot } from './components/Picker/Picker.js' import { DEFAULT_DATA_SOURCE, DEFAULT_LOCALE } from '../database/constants' import { DEFAULT_CATEGORY_SORTING, DEFAULT_SKIN_TONE_EMOJI, FONT_FAMILY } from './constants' import enI18n from './i18n/en.js' @@ -52,10 +51,7 @@ export default class PickerElement extends HTMLElement { // The _cmp may be defined if the component was immediately disconnected and then reconnected. In that case, // do nothing (preserve the state) if (!this._cmp) { - this._cmp = createRoot(SveltePicker, { - target: this.shadowRoot, - props: this._ctx - }) + this._cmp = createRoot(this.shadowRoot, this._ctx) } } diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index 65615970..29e12316 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -21,13 +21,14 @@ import { requestAnimationFrame } from '../../utils/requestAnimationFrame' import { uniq } from '../../../shared/uniq' import { resetScrollTopIfPossible } from '../../utils/resetScrollTopIfPossible.js' import { createRootDom } from './PickerTemplate.js' +import { createState } from './reactivity.js' // constants const EMPTY_ARRAY = [] const { assign } = Object -export function createRoot(target, props) { +export function createRoot (target, props) { const { state, createEffect } = createState() const destroyCallbacks = [] @@ -38,7 +39,7 @@ export function createRoot(target, props) { database: undefined, customEmoji: undefined, customCategorySorting: undefined, - emojiVersion: undefined, + emojiVersion: undefined }) // public props @@ -70,25 +71,25 @@ export function createRoot(target, props) { currentGroupIndex: 0, groups: defaultGroups, databaseLoaded: false, - activeSearchItemId: undefined, + activeSearchItemId: undefined }) -// -// Update the current group based on the currentGroupIndex -// + // + // Update the current group based on the currentGroupIndex + // createEffect(() => { state.currentGroup = state.groups[state.currentGroupIndex] }) - -// -// Utils/helpers -// + + // + // Utils/helpers + // const focus = id => { rootNode.getRootNode().getElementById(id).focus() } -// fire a custom event that crosses the shadow boundary + // fire a custom event that crosses the shadow boundary const fireEvent = (name, detail) => { rootNode.dispatchEvent(new CustomEvent(name, { detail, @@ -98,7 +99,7 @@ export function createRoot(target, props) { } // Helpers - + const unicodeWithSkin = (emoji, currentSkinTone) => ( (currentSkinTone && emoji.skins && emoji.skins[currentSkinTone]) || emoji.unicode ) @@ -127,32 +128,33 @@ export function createRoot(target, props) { onSkinToneOptionsClick, onSkinToneOptionsFocusOut, onSkinToneOptionsKeydown, - onSkinToneOptionsKeyup + onSkinToneOptionsKeyup, + onSearchInput } const { refs, rootNode } = createRootDom(state, helpers, events, createEffect) - -// -// Determine the emoji support level (in requestIdleCallback) -// + + // + // Determine the emoji support level (in requestIdleCallback) + // // mount logic - if (!state.emojiVersion) { - detectEmojiSupportLevel().then(level => { - // Can't actually test emoji support in Jest/JSDom, emoji never render in color in Cairo - /* istanbul ignore next */ - if (!level) { - state.message = state.i18n.emojiUnsupportedMessage - } - }) - } + if (!state.emojiVersion) { + detectEmojiSupportLevel().then(level => { + // Can't actually test emoji support in Jest/JSDom, emoji never render in color in Cairo + /* istanbul ignore next */ + if (!level) { + state.message = state.i18n.emojiUnsupportedMessage + } + }) + } -// -// Set or update the database object -// + // + // Set or update the database object + // createEffect(() => { // show a Loading message if it takes a long time, or show an error if there's a network/IDB error @@ -163,7 +165,7 @@ export function createRoot(target, props) { state.message = state.i18n.loadingMessage }, TIMEOUT_BEFORE_LOADING_MESSAGE) try { - await database.ready() + await state.database.ready() state.databaseLoaded = true // eslint-disable-line no-unused-vars } catch (err) { console.error(err) @@ -183,9 +185,9 @@ export function createRoot(target, props) { } }) -// -// Global styles for the entire picker -// + // + // Global styles for the entire picker + // createEffect(() => { state.pickerStyle = ` @@ -194,9 +196,9 @@ export function createRoot(target, props) { --num-skintones: ${NUM_SKIN_TONES};` }) -// -// Set or update the customEmoji -// + // + // Set or update the customEmoji + // createEffect(() => { if (state.customEmoji && state.database) { @@ -218,9 +220,9 @@ export function createRoot(target, props) { } }) -// -// Set or update the preferred skin tone -// + // + // Set or update the preferred skin tone + // createEffect(() => { async function updatePreferredSkinTone () { @@ -236,17 +238,17 @@ export function createRoot(target, props) { state.skinTones = Array(NUM_SKIN_TONES).fill().map((_, i) => applySkinTone(state.skinToneEmoji, i)) }) -createEffect(() => { - state.skinToneButtonText = state.skinTones[state.currentSkinTone] -}) + createEffect(() => { + state.skinToneButtonText = state.skinTones[state.currentSkinTone] + }) createEffect(() => { state.skinToneButtonLabel = state.i18n.skinToneLabel.replace('{skinTone}', state.i18n.skinTones[state.currentSkinTone]) }) -// -// Set or update the favorites emojis -// + // + // Set or update the favorites emojis + // createEffect(() => { async function updateDefaultFavoriteEmojis () { @@ -277,18 +279,18 @@ createEffect(() => { } }) -// -// Calculate the width of the emoji grid. This serves two purposes: -// 1) Re-calculate the --num-columns var because it may have changed -// 2) Re-calculate the scrollbar width because it may have changed -// (i.e. because the number of items changed) -// 3) Re-calculate whether we're in RTL mode or not. -// -// The benefit of doing this in one place is to align with rAF/ResizeObserver -// and do all the calculations in one go. RTL vs LTR is not strictly width-related, -// but since we're already reading the style here, and since it's already aligned with -// the rAF loop, this is the most appropriate place to do it perf-wise. -// + // + // Calculate the width of the emoji grid. This serves two purposes: + // 1) Re-calculate the --num-columns var because it may have changed + // 2) Re-calculate the scrollbar width because it may have changed + // (i.e. because the number of items changed) + // 3) Re-calculate whether we're in RTL mode or not. + // + // The benefit of doing this in one place is to align with rAF/ResizeObserver + // and do all the calculations in one go. RTL vs LTR is not strictly width-related, + // but since we're already reading the style here, and since it's already aligned with + // the rAF loop, this is the most appropriate place to do it perf-wise. + // function calculateEmojiGridStyle (node) { return widthCalculator.calculateWidth(node, width => { @@ -309,16 +311,15 @@ createEffect(() => { }) } - // calculate the width and clean up on destroy - const calculator = calculateEmojiGridStyle(refs.emojiGrid) + // calculate the width and clean up on destroy + const calculator = calculateEmojiGridStyle(refs.emojiGrid) destroyCallbacks.push(calculator.destroy) - -// -// Set or update the currentEmojis. Check for invalid ZWJ renderings -// (i.e. double emoji). -// + // + // Set or update the currentEmojis. Check for invalid ZWJ renderings + // (i.e. double emoji). + // createEffect(() => { async function updateEmojis () { @@ -346,9 +347,9 @@ createEffect(() => { /* no await */ updateEmojis() }) -// Some emojis have their ligatures rendered as two or more consecutive emojis -// We want to treat these the same as unsupported emojis, so we compare their -// widths against the baseline widths and remove them as necessary + // Some emojis have their ligatures rendered as two or more consecutive emojis + // We want to treat these the same as unsupported emojis, so we compare their + // widths against the baseline widths and remove them as necessary createEffect(() => { const zwjEmojisToCheck = state.currentEmojis .filter(emoji => emoji.unicode) // filter custom emoji @@ -364,8 +365,8 @@ createEffect(() => { }) function checkZwjSupportAndUpdate (zwjEmojisToCheck) { - const rootNode = rootNode.getRootNode() - const emojiToDomNode = emoji => rootNode.getElementById(`emo-${emoji.id}`) + const shadowRootNode = rootNode.getRootNode() + const emojiToDomNode = emoji => shadowRootNode.getElementById(`emo-${emoji.id}`) checkZwjSupport(zwjEmojisToCheck, refs.baselineEmoji, emojiToDomNode) // force update const { currentEmojis } = state @@ -408,10 +409,10 @@ createEffect(() => { } }) -// -// Derive currentEmojisWithCategories from currentEmojis. This is always done even if there -// are no categories, because it's just easier to code the HTML this way. -// + // + // Derive currentEmojisWithCategories from currentEmojis. This is always done even if there + // are no categories, because it's just easier to code the HTML this way. + // createEffect(() => { function calculateCurrentEmojisWithCategories () { @@ -441,17 +442,17 @@ createEffect(() => { state.currentEmojisWithCategories = calculateCurrentEmojisWithCategories() }) -// -// Handle active search item (i.e. pressing up or down while searching) -// + // + // Handle active search item (i.e. pressing up or down while searching) + // createEffect(() => { state.activeSearchItemId = state.activeSearchItem !== -1 && state.currentEmojis[state.activeSearchItem].id }) -// -// Handle user input on the search input -// + // + // Handle user input on the search input + // createEffect(() => { const { rawSearchText } = state @@ -486,9 +487,9 @@ createEffect(() => { } } -// -// Handle user input on nav -// + // + // Handle user input on nav + // function onNavClick (group) { refs.searchElement.value = '' // clear search box input @@ -520,9 +521,9 @@ createEffect(() => { } } -// -// Handle user input on an emoji -// + // + // Handle user input on an emoji + // async function clickEmoji (unicodeOrName) { const emoji = await state.database.getEmojiByUnicodeOrName(unicodeOrName) @@ -549,16 +550,16 @@ createEffect(() => { /* no await */ clickEmoji(id) } -// -// Handle user input on the skintone picker -// + // + // Handle user input on the skintone picker + // function changeSkinTone (skinTone) { state.currentSkinTone = skinTone state.skinTonePickerExpanded = false focus('skintone-button') fireEvent('skin-tone-change', { skinTone }) - /* no await */ database.setPreferredSkinTone(skinTone) + /* no await */ state.database.setPreferredSkinTone(skinTone) } function onSkinToneOptionsClick (event) { @@ -581,9 +582,9 @@ createEffect(() => { } } -// To make the animation nicer, change the z-index of the skintone picker button -// *after* the animation has played. This makes it appear that the picker box -// is expanding "below" the button + // To make the animation nicer, change the z-index of the skintone picker button + // *after* the animation has played. This makes it appear that the picker box + // is expanding "below" the button createEffect(() => { if (state.skinTonePickerExpanded) { refs.skinToneDropdown.addEventListener('transitionend', () => { @@ -646,18 +647,18 @@ createEffect(() => { } } - function onSearchInput (event) { - state.rawSearchText = event.target.value - } + function onSearchInput (event) { + state.rawSearchText = event.target.value + } - target.appendChild(rootNode) + target.appendChild(rootNode) - return { - destroy () { - rootNode.remove() - for (const destroyCallback of destroyCallbacks) { - destroyCallback() - } + return { + destroy () { + rootNode.remove() + for (const destroyCallback of destroyCallbacks) { + destroyCallback() } } -} \ No newline at end of file + } +} diff --git a/src/picker/components/Picker/PickerTemplate.js b/src/picker/components/Picker/PickerTemplate.js index 18ccf1b1..508bf724 100644 --- a/src/picker/components/Picker/PickerTemplate.js +++ b/src/picker/components/Picker/PickerTemplate.js @@ -1,7 +1,6 @@ import { html, map } from './framework.js' export function createRootDom (state, helpers, events, createEffect) { - const { labelWithSkin, titleForEmoji, unicodeWithSkin } = helpers const emojiList = createEffect((emojis, searchMode, prefix) => { @@ -122,13 +121,15 @@ export function createRootDom (state, helpers, events, createEffect) { from "Custom" to "Smileys and emoticons" when you click the second nav button. The easiest way to repro this is to add an artificial delay to the IndexedDB operations. --> ${ - state.searchMode ? - state.i18n.searchResultsLabel : ( - emojiWithCategory.category ? - emojiWithCategory.category : ( - state.currentEmojisWithCategories.length > 1 ? - state.i18n.categories.custom : - state.i18n.categories[state.currentGroup.name] + state.searchMode + ? state.i18n.searchResultsLabel +: ( + emojiWithCategory.category + ? emojiWithCategory.category +: ( + state.currentEmojisWithCategories.length > 1 + ? state.i18n.categories.custom + : state.i18n.categories[state.currentGroup.name] ) ) } @@ -168,7 +169,7 @@ export function createRootDom (state, helpers, events, createEffect) { ` + }) }) - }) + } const skintoneButtons = createEffect(() => { return map(state.skinTones, (skinTone, i) => { @@ -43,6 +48,8 @@ export function createRootDom (state, helpers, events, createEffect) { // The one case where we deviate from the example is that we move focus from the button to the // listbox. (The example uses a combobox, so it's not exactly the same.) This was tested in NVDA and VoiceOver. return html` + +
+
` }) @@ -101,7 +109,7 @@ export function createRootDom (state, helpers, events, createEffect) { data-ref="searchElement" data-on-input="onSearchInput" data-on-keydown="onSearchKeydown" - > + > ${state.i18n.searchDescription} @@ -112,6 +120,8 @@ export function createRootDom (state, helpers, events, createEffect) { const emojiTabs = createEffect(() => { return map(state.currentEmojisWithCategories, (emojiWithCategory, i) => { return html` + +
` }) @@ -224,7 +235,7 @@ export function createRootDom (state, helpers, events, createEffect) { aria-label="${state.i18n.favoritesLabel}" style="padding-inline-end: ${state.scrollbarWidth}px" data-on-click="onEmojiClick"> - ${emojiList(state.currentFavorites, false, 'fav')} + ${emojiList(state.currentFavorites, false, 'fav')()} ` - }) }) } - const skintoneButtons = createEffect(() => { + const favorites = createHtmlEffect(() => { + console.log('state.currentFavorites', state.currentFavorites) + return html` + + ` + }) + + const skintoneButtons = createHtmlEffect(() => { return map(state.skinTones, (skinTone, i) => { + const { html } = createFramework() return html`
{ - // For the pattern used for the skintone dropdown, see: - // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/ - // The one case where we deviate from the example is that we move focus from the button to the - // listbox. (The example uses a combobox, so it's not exactly the same.) This was tested in NVDA and VoiceOver. - return html` - -
-
- -
- ${state.i18n.skinToneDescription} -
- ${skintoneButtons()} -
-
- ` - }) - - const searchBox = createEffect(() => { + const searchBox = createHtmlEffect(() => { return html`
@@ -117,45 +89,52 @@ export function createRootDom (state, helpers, events, createEffect) { ` }) - const emojiTabs = createEffect(() => { - return map(state.currentEmojisWithCategories, (emojiWithCategory, i) => { - return html` + const emojiTabs = createHtmlEffect(() => { + return html` +
+ ${ + map(state.currentEmojisWithCategories, (emojiWithCategory, i) => { + const { html } = createFramework() + return html`
- -
- ${emojiList(emojiWithCategory.emojis, state.searchMode, 'emo')()} -
+ +
+ ${emojiList(emojiWithCategory.emojis, state.searchMode, 'emo')} +
` - }) + }) + } +
+ ` }) - const emojiTabPanel = createEffect(() => { + const emojiTabPanel = createHtmlEffect(() => { // The tabindex=0 is so people can scroll up and down with the keyboard. The element has a role and a label, so I // feel it's appropriate to have the tabindex. // This on:click is a delegated click listener @@ -167,15 +146,14 @@ export function createRootDom (state, helpers, events, createEffect) { tabindex="0" data-on-click="onEmojiClick" > -
- ${emojiTabs()} -
+ ${emojiTabs()}
` }) - const navButtons = createEffect(() => { + const navButtons = createHtmlEffect(() => { return map(state.groups, (group) => { + const { html } = createFramework() return html` +
+ ${state.i18n.skinToneDescription} +
+ ${skintoneButtons()} +
${nav()}
- + ${favorites()} +
+ ${state.i18n.skinToneDescription} +
+ ${skintoneButtons()} +
` }) @@ -161,8 +195,8 @@ export function createRootDom (state, helpers, events, createEffect) { aria-label="${state.i18n.categories[group.name]}" aria-selected="${!state.searchMode && state.currentGroup.id === group.id}" title="${state.i18n.categories[group.name]}" - data-on-click="() => onNavClick(group)"> - ` @@ -191,41 +227,7 @@ export function createRootDom (state, helpers, events, createEffect) { style="${state.pickerStyle}">
- ${searchBox()} - -
- -
- ${state.i18n.skinToneDescription} -
- ${skintoneButtons()} -
+ ${searchRow()} ${nav()}
Date: Sun, 19 Nov 2023 22:57:57 -0800 Subject: [PATCH 028/113] fix: try to improve reactivity --- .../components/Picker/PickerTemplate.js | 95 +++--- src/picker/components/Picker/framework.js | 306 +++++++++--------- src/picker/components/Picker/reactivity.js | 9 +- 3 files changed, 212 insertions(+), 198 deletions(-) diff --git a/src/picker/components/Picker/PickerTemplate.js b/src/picker/components/Picker/PickerTemplate.js index 62ced46a..2e250b85 100644 --- a/src/picker/components/Picker/PickerTemplate.js +++ b/src/picker/components/Picker/PickerTemplate.js @@ -1,16 +1,25 @@ -import { createFramework } from './framework.js' +import { html, map } from './framework.js' export function createRootDom (state, helpers, events, createEffect) { - const { html, map } = createFramework() const { labelWithSkin, titleForEmoji, unicodeWithSkin } = helpers - // const createHtmlEffect = callback => () => callback() - const createHtmlEffect = callback => createEffect(callback, true) + const frameworkEffect = (callback) => { + return createEffect(() => { + const result = callback() + if (Array.isArray(result)) { + for (const subResult of result) { + subResult.update(subResult.expressions) + } + } else { + result.update(result.expressions) + } + return result + }) + } const emojiList = (emojis, searchMode, prefix) => { - return map(emojis, (emoji, i) => { - const { html } = createFramework() - return html` + return frameworkEffect(() => map(emojis, (emoji, i) => { + return frameworkEffect(() => html` - ` - }) + `) + })) } - const favorites = createHtmlEffect(() => { - console.log('state.currentFavorites', state.currentFavorites) - return html` + const favorites = (() => { + return frameworkEffect(() => html` - ` + `) }) - const skintoneButtons = createHtmlEffect(() => { - return map(state.skinTones, (skinTone, i) => { - const { html } = createFramework() - return html` + const skintoneButtons = () => { + return frameworkEffect(() => map(state.skinTones, (skinTone, i) => { + return frameworkEffect(() => html`
${skinTone}
- ` - }) - }) + `) + })) + } - const searchRow = createHtmlEffect(() => { - return html` + const searchRow = (() => { + return frameworkEffect(() => html`
- ` + `) }) }
- ` + `) }) - const emojiTabPanel = createHtmlEffect(() => { + const emojiTabPanel = (() => { // The tabindex=0 is so people can scroll up and down with the keyboard. The element has a role and a label, so I // feel it's appropriate to have the tabindex. // This on:click is a delegated click listener - return html` + return frameworkEffect(() => html`
${emojiTabs()}
- ` + `) }) - const navButtons = createHtmlEffect(() => { - return map(state.groups, (group) => { - const { html } = createFramework() - return html` + const navButtons = () => { + return frameworkEffect(() => map(state.groups, (group) => { + return frameworkEffect(() => html` - ` - }) - }) + `) + })) + } - const nav = createHtmlEffect(() => { + const nav = (() => { // this is interactive because of keydown; it doesn't really need focus - return html` + return frameworkEffect(() => html` - ` + `) }) - const section = createHtmlEffect(() => { - return html` + const section = (() => { + return frameworkEffect(() => html`
- ` + `) }) const { dom: rootDom } = section() diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index 36731f70..9424b3e6 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -55,7 +55,7 @@ function toString (value) { return value.toString() } -function parseDom (htmlString) { +function parseTemplate (htmlString) { const template = document.createElement('template') template.innerHTML = htmlString @@ -64,98 +64,14 @@ function parseDom (htmlString) { throw new Error('only 1 child allowed for now') } } - return template.content.firstElementChild + return template } -export function createFramework () { - function parse (tokens) { - let htmlString = '' +function createUpdater () { + return (dom, boundExpressions) => { + traverseAndSetupBindings(dom, boundExpressions) - let withinTag = false - let withinAttribute = false - let elementIndexCounter = -1 // depth-first traversal order - - const boundExpressions = new Map() - - const elementIndexes = [] - - const push = () => { - elementIndexes.push(++elementIndexCounter) - } - - const pop = () => { - elementIndexes.pop() - } - - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i] - htmlString += token - - if (i === tokens.length - 1) { - break // no need to process characters - } - - for (let j = 0; j < token.length; j++) { - const char = token.charAt(j) - switch (char) { - case '<': { - const nextChar = token.charAt(j + 1) - if (nextChar !== '!' && nextChar !== '/') { // not a closing tag or comment - withinTag = true - push() - } else if (nextChar === '/') { - // leaving an element - pop() - } - break - } - case '>': { - withinTag = false - withinAttribute = false - break - } - case '=': { - if (withinTag) { - withinAttribute = true - } - break - } - } - } - - const elementIndex = elementIndexes[elementIndexes.length - 1] - let bindings = boundExpressions.get(elementIndex) - if (!bindings) { - bindings = [] - boundExpressions.set(elementIndex, bindings) - } - - let attributeName - let attributeValuePre - let attributeValuePost - if (withinAttribute) { - attributeName = /(\S+)="?(?:[^"]+)?$/.exec(token)[1] - attributeValuePre = /="?([^"=]*)$/.exec(token)[1] - attributeValuePost = /^([^">]*)/.exec(tokens[i + 1])[1] - } - - bindings.push({ - withinTag, - withinAttribute, - attributeName, - attributeValuePre, - attributeValuePost, - expressionIndex: i - }) - - htmlString += (!withinTag && !withinAttribute) ? `` : '' - } - - const dom = parseDom(htmlString) - - traverseAndSetupBindings(tokens, dom, boundExpressions) - - const update = (expressions) => { + return (expressions) => { for (const bindings of boundExpressions.values()) { for (const binding of bindings) { const { @@ -223,80 +139,176 @@ export function createFramework () { } } } + } +} - return { - dom, - boundExpressions, - update - } +function parse (tokens) { + let htmlString = '' + + let withinTag = false + let withinAttribute = false + let elementIndexCounter = -1 // depth-first traversal order + + const boundExpressions = new Map() + + const elementIndexes = [] + + const push = () => { + elementIndexes.push(++elementIndexCounter) + } + + const pop = () => { + elementIndexes.pop() } - const parseCache = new WeakMap() + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i] + htmlString += token - function parseWithCache (tokens) { - let cached = parseCache.get(tokens) - if (!cached) { - cached = parse(tokens) - parseCache.set(tokens, cached) + if (i === tokens.length - 1) { + break // no need to process characters } - return cached - } - function traverseAndSetupBindings (tokens, dom, boundExpressions) { - // traverse dom - const treeWalker = document.createTreeWalker(dom, NodeFilter.SHOW_ELEMENT) - - let element = dom - let elementIndex = -1 - do { - const bindings = boundExpressions.get(++elementIndex) - if (bindings) { - let foundComments - for (let i = 0; i < bindings.length; i++) { - const binding = bindings[i] - - binding.element = element - - if (!binding.withinAttribute) { - if (!foundComments) { - // find all comments once - foundComments = new Map() - for (const childNode of element.childNodes) { - let match - if (childNode.nodeType === Node.COMMENT_NODE && (match = /^placeholder-(\d+)/.exec(childNode.textContent))) { - foundComments.set(parseInt(match[1], 10), childNode) - } - } - } - binding.targetNode = foundComments.get(i) + for (let j = 0; j < token.length; j++) { + const char = token.charAt(j) + switch (char) { + case '<': { + const nextChar = token.charAt(j + 1) + if (nextChar !== '!' && nextChar !== '/') { // not a closing tag or comment + withinTag = true + push() + } else if (nextChar === '/') { + // leaving an element + pop() } + break + } + case '>': { + withinTag = false + withinAttribute = false + break + } + case '=': { + if (withinTag) { + withinAttribute = true + } + break } } - } while ((element = treeWalker.nextNode())) + } + + const elementIndex = elementIndexes[elementIndexes.length - 1] + let bindings = boundExpressions.get(elementIndex) + if (!bindings) { + bindings = [] + boundExpressions.set(elementIndex, bindings) + } + + let attributeName + let attributeValuePre + let attributeValuePost + if (withinAttribute) { + attributeName = /(\S+)="?(?:[^"]+)?$/.exec(token)[1] + attributeValuePre = /="?([^"=]*)$/.exec(token)[1] + attributeValuePost = /^([^">]*)/.exec(tokens[i + 1])[1] + } + + bindings.push({ + withinTag, + withinAttribute, + attributeName, + attributeValuePre, + attributeValuePost, + expressionIndex: i + }) + + htmlString += (!withinTag && !withinAttribute) ? `` : '' } - function html (tokens, ...expressions) { - const { - dom, - boundExpressions, - update - } = parseWithCache(tokens) + const template = parseTemplate(htmlString) - update(expressions) + return { + template, + boundExpressions, + } +} - return { - dom, - boundExpressions, - [isHtmlTagTemplateExpression]: true - } +const parseCache = new WeakMap() + +function parseWithCache (tokens) { + let cached = parseCache.get(tokens) + if (!cached) { + cached = parse(tokens) + parseCache.set(tokens, cached) } + return cached +} - function map (array, callback) { - return array.map(callback) +function cloneBoundExpressions(boundExpressions) { + const map = new Map() + for (const [id, bindings] of boundExpressions.entries()) { + map.set(id, bindings.map(_ => structuredClone(_))) } + return map +} + +function traverseAndSetupBindings (dom, boundExpressions) { + // traverse dom + const treeWalker = document.createTreeWalker(dom, NodeFilter.SHOW_ELEMENT) + + let element = dom + let elementIndex = -1 + do { + const bindings = boundExpressions.get(++elementIndex) + if (bindings) { + let foundComments + for (let i = 0; i < bindings.length; i++) { + const binding = bindings[i] + + binding.element = element + + if (!binding.withinAttribute) { + if (!foundComments) { + // find all comments once + foundComments = new Map() + for (const childNode of element.childNodes) { + let match + if (childNode.nodeType === Node.COMMENT_NODE && (match = /^placeholder-(\d+)/.exec(childNode.textContent))) { + foundComments.set(parseInt(match[1], 10), childNode) + } + } + } + binding.targetNode = foundComments.get(i) + } + } + } + } while ((element = treeWalker.nextNode())) +} + +function html (tokens, ...expressions) { + const { + template, + boundExpressions + } = parseWithCache(tokens) + + const updater = createUpdater() + const clonedDom = template.cloneNode(true).content.firstElementChild + const clonedBoundExpressions = cloneBoundExpressions(boundExpressions) + const update = updater(clonedDom, clonedBoundExpressions) return { - html, - map + dom: clonedDom, + expressions, + update, + [isHtmlTagTemplateExpression]: true } } + +function map (array, callback) { + return array.map(callback) +} + +export { + html, + map +} diff --git a/src/picker/components/Picker/reactivity.js b/src/picker/components/Picker/reactivity.js index 0bed35e6..f93c5f80 100644 --- a/src/picker/components/Picker/reactivity.js +++ b/src/picker/components/Picker/reactivity.js @@ -46,19 +46,16 @@ export function createState () { } }) - const createEffect = (callback, noInit) => { + const createEffect = (callback) => { const runnable = () => { - currentObserver = callback + currentObserver = runnable try { return callback() } finally { currentObserver = undefined } } - if (!noInit) { - runnable() - } - return runnable + return runnable() } return { From 079bcd34035ffbbfdf7e2947affcd405ee682652 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Mon, 20 Nov 2023 08:11:19 -0800 Subject: [PATCH 029/113] fix: try to make progress --- .../components/Picker/PickerTemplate.js | 303 +++++++++--------- src/picker/components/Picker/framework.js | 45 +-- src/picker/components/Picker/reactivity.js | 7 +- 3 files changed, 175 insertions(+), 180 deletions(-) diff --git a/src/picker/components/Picker/PickerTemplate.js b/src/picker/components/Picker/PickerTemplate.js index 2e250b85..3324a43e 100644 --- a/src/picker/components/Picker/PickerTemplate.js +++ b/src/picker/components/Picker/PickerTemplate.js @@ -1,71 +1,27 @@ -import { html, map } from './framework.js' +import { html as frameworkHtml } from './framework.js' export function createRootDom (state, helpers, events, createEffect) { const { labelWithSkin, titleForEmoji, unicodeWithSkin } = helpers - const frameworkEffect = (callback) => { - return createEffect(() => { - const result = callback() - if (Array.isArray(result)) { - for (const subResult of result) { - subResult.update(subResult.expressions) - } - } else { - result.update(result.expressions) - } - return result - }) + function html (tokens, ...expressions) { + const { update } = frameworkHtml(tokens) + const { dom } = update(expressions) + return { dom, update } } - const emojiList = (emojis, searchMode, prefix) => { - return frameworkEffect(() => map(emojis, (emoji, i) => { - return frameworkEffect(() => html` - - `) - })) + function map (array, callback) { + return array.map(callback) } - const favorites = (() => { - return frameworkEffect(() => html` - - `) - }) - - const skintoneButtons = () => { - return frameworkEffect(() => map(state.skinTones, (skinTone, i) => { - return frameworkEffect(() => html` -
- ${skinTone} -
- `) - })) - } - - const searchRow = (() => { - return frameworkEffect(() => html` -
+ const section = () => { + return html` +
+ +
+
-
- -
- ${emojiList(emojiWithCategory.emojis, state.searchMode, 'emo')} -
+ map(state.skinTones, (skinTone, i) => { + return html` +
+ ${skinTone}
- `) - }) + ` + }) }
- `) - }) - - const emojiTabPanel = (() => { - // The tabindex=0 is so people can scroll up and down with the keyboard. The element has a role and a label, so I - // feel it's appropriate to have the tabindex. - // This on:click is a delegated click listener - return frameworkEffect(() => html` -
- ${emojiTabs()}
- `) - }) - - const navButtons = () => { - return frameworkEffect(() => map(state.groups, (group) => { - return frameworkEffect(() => html` + +
+ ${ + map(state.groups, (group) => { + return html` - `) - })) - } - - const nav = (() => { - // this is interactive because of keydown; it doesn't really need focus - return frameworkEffect(() => html` - - `) - }) - - const section = (() => { - return frameworkEffect(() => html` -
- -
- ${searchRow()} - ${nav()} + ` + }) + } +
@@ -246,16 +134,115 @@ export function createRootDom (state, helpers, events, createEffect) { ${state.message}
- ${emojiTabPanel()} + +
+
+ ${ + map(state.currentEmojisWithCategories, (emojiWithCategory, i) => { + return html` + +
+ +
+ ${ + (() => { + const emojis = emojiWithCategory.emojis + const searchMode = state.searchMode + const prefix = 'emo' + return map(emojis, (emoji, i) => { + return html` + + ` + }) + })() + } +
+
+ ` + }) + } +
+
- ${favorites()} +
- `) - }) + ` + } const { dom: rootDom } = section() diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index 9424b3e6..34d17b61 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -1,5 +1,3 @@ -const isHtmlTagTemplateExpression = Symbol('html-tag-template-expression') - // via https://github.com/component/escape-html/blob/b42947eefa79efff01b3fe988c4c7e7b051ec8d8/index.js function escapeHtml (string) { const str = '' + string @@ -114,14 +112,14 @@ function createUpdater () { } const { iteratorEndNode } = binding for (const subExpression of expression) { - if (subExpression && subExpression[isHtmlTagTemplateExpression]) { // html tag template itself + if (subExpression && subExpression.dom) { // html tag template itself parentNode.insertBefore(subExpression.dom, iteratorEndNode) } else { // primitive - string, number, etc const textNode = document.createTextNode(toString(subExpression)) parentNode.insertBefore(textNode, iteratorEndNode) } } - } else if (expression && expression[isHtmlTagTemplateExpression]) { // html tag template itself + } else if (expression && expression.dom) { // html tag template itself newNode = expression.dom targetNode.replaceWith(newNode) } else { // primitive - string, number, etc @@ -229,7 +227,7 @@ function parse (tokens) { return { template, - boundExpressions, + boundExpressions } } @@ -244,7 +242,7 @@ function parseWithCache (tokens) { return cached } -function cloneBoundExpressions(boundExpressions) { +function cloneBoundExpressions (boundExpressions) { const map = new Map() for (const [id, bindings] of boundExpressions.entries()) { map.set(id, bindings.map(_ => structuredClone(_))) @@ -285,30 +283,35 @@ function traverseAndSetupBindings (dom, boundExpressions) { } while ((element = treeWalker.nextNode())) } -function html (tokens, ...expressions) { +function html (tokens) { const { template, boundExpressions } = parseWithCache(tokens) - const updater = createUpdater() - const clonedDom = template.cloneNode(true).content.firstElementChild - const clonedBoundExpressions = cloneBoundExpressions(boundExpressions) - const update = updater(clonedDom, clonedBoundExpressions) + let updater + let clonedDom + let clonedBoundExpressions - return { - dom: clonedDom, - expressions, - update, - [isHtmlTagTemplateExpression]: true + const update = () => { + if (!updater) { + updater = createUpdater() + clonedDom = template.cloneNode(true).content.firstElementChild + clonedBoundExpressions = cloneBoundExpressions(boundExpressions) + } + + updater(clonedDom, clonedBoundExpressions) + + return { + dom: clonedDom + } } -} -function map (array, callback) { - return array.map(callback) + return { + update + } } export { - html, - map + html } diff --git a/src/picker/components/Picker/reactivity.js b/src/picker/components/Picker/reactivity.js index f93c5f80..1383126e 100644 --- a/src/picker/components/Picker/reactivity.js +++ b/src/picker/components/Picker/reactivity.js @@ -48,16 +48,21 @@ export function createState () { const createEffect = (callback) => { const runnable = () => { + const oldObserver = currentObserver currentObserver = runnable try { return callback() } finally { - currentObserver = undefined + currentObserver = oldObserver } } return runnable() } + if (process.env.NODE_ENV !== 'production') { + window.state = state + } + return { state, createEffect From 59301c066891860ab54bd4d7fc1da1278fae7dda Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Mon, 20 Nov 2023 19:56:01 -0800 Subject: [PATCH 030/113] fix: back to working again --- src/picker/components/Picker/Picker.js | 41 ++++++++++++------- .../components/Picker/PickerTemplate.js | 17 +++++--- src/picker/components/Picker/framework.js | 7 +++- 3 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index 8500b71f..3d63a99d 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -86,12 +86,12 @@ export function createRoot (target, props) { // const focus = id => { - rootNode.getRootNode().getElementById(id).focus() + refs.rootElement.getRootNode().getElementById(id).focus() } // fire a custom event that crosses the shadow boundary const fireEvent = (name, detail) => { - rootNode.dispatchEvent(new CustomEvent(name, { + refs.rootElement.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true @@ -132,10 +132,20 @@ export function createRoot (target, props) { onSearchInput } - const { - refs, - rootNode - } = createRootDom(state, helpers, events, createEffect) + let renderedRootNode + let refs + createEffect(() => { + const { + refs: theRefs, + rootNode + } = createRootDom(state, helpers, events) + if (renderedRootNode) { + renderedRootNode.replaceWith(rootNode) + } + renderedRootNode = rootNode + target.appendChild(rootNode) + refs = theRefs + }) // // Determine the emoji support level (in requestIdleCallback) @@ -299,7 +309,7 @@ export function createRoot (target, props) { /* istanbul ignore next */ if (process.env.NODE_ENV !== 'test') { // jsdom throws errors for this kind of fancy stuff // read all the style/layout calculations we need to make - const style = getComputedStyle(rootNode) + const style = getComputedStyle(refs.rootElement) const newNumColumns = parseInt(style.getPropertyValue('--num-columns'), 10) const newIsRtl = style.getPropertyValue('direction') === 'rtl' const parentWidth = node.parentElement.getBoundingClientRect().width @@ -314,9 +324,12 @@ export function createRoot (target, props) { } // calculate the width and clean up on destroy - const calculator = calculateEmojiGridStyle(refs.emojiGrid) - - destroyCallbacks.push(calculator.destroy) + // createEffect(() => { + // if (refs && refs.emojiGrid) { + // const calculator = calculateEmojiGridStyle(refs.emojiGrid) + // destroyCallbacks.push(calculator.destroy) + // } + // }) // // Set or update the currentEmojis. Check for invalid ZWJ renderings @@ -367,7 +380,7 @@ export function createRoot (target, props) { }) function checkZwjSupportAndUpdate (zwjEmojisToCheck) { - const shadowRootNode = rootNode.getRootNode() + const shadowRootNode = refs.rootElement.getRootNode() const emojiToDomNode = emoji => shadowRootNode.getElementById(`emo-${emoji.id}`) checkZwjSupport(zwjEmojisToCheck, refs.baselineEmoji, emojiToDomNode) // force update @@ -658,14 +671,14 @@ export function createRoot (target, props) { state.rawSearchText = event.target.value } - target.appendChild(rootNode) - return { $set (newState) { assign(state, newState) }, $destroy () { - rootNode.remove() + if (renderedRootNode) { + renderedRootNode.remove() + } for (const destroyCallback of destroyCallbacks) { destroyCallback() } diff --git a/src/picker/components/Picker/PickerTemplate.js b/src/picker/components/Picker/PickerTemplate.js index 3324a43e..045aec78 100644 --- a/src/picker/components/Picker/PickerTemplate.js +++ b/src/picker/components/Picker/PickerTemplate.js @@ -1,12 +1,12 @@ import { html as frameworkHtml } from './framework.js' -export function createRootDom (state, helpers, events, createEffect) { +export function createRootDom (state, helpers, events) { const { labelWithSkin, titleForEmoji, unicodeWithSkin } = helpers function html (tokens, ...expressions) { const { update } = frameworkHtml(tokens) const { dom } = update(expressions) - return { dom, update } + return { dom } } function map (array, callback) { @@ -16,6 +16,7 @@ export function createRootDom (state, helpers, events, createEffect) { const section = () => { return html`
@@ -97,7 +98,7 @@ export function createRootDom (state, helpers, events, createEffect) {
-
@@ -120,7 +146,7 @@ export function createRootDom (state, helpers, events) {
` - }) + }, group => group.id) }
@@ -197,13 +223,13 @@ export function createRootDom (state, helpers, events) { } ` - }) + }, emoji => emoji.id) })() }
` - }) + }, emojiWithCategory => emojiWithCategory.category) } @@ -233,7 +259,7 @@ export function createRootDom (state, helpers, events) { } ` - }) + }, emoji => emoji.id) })() } @@ -246,7 +272,7 @@ export function createRootDom (state, helpers, events) { } const { - dom: rootDom, + dom: rootDom } = section() // bind events diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index 648594e3..2fada4da 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -98,7 +98,6 @@ function createUpdater () { if (expression && Array.isArray(expression)) { // array of html tag templates const { parentNode } = targetNode if (binding.iteratorEndNode) { // already rendered once - clean up - console.log('re-render') let currentSibling = targetNode.nextSibling while (!(currentSibling.nodeType === Node.COMMENT_NODE && currentSibling.textContent === 'end')) { const oldSibling = currentSibling @@ -277,6 +276,9 @@ function traverseAndSetupBindings (dom, boundExpressions) { } } binding.targetNode = foundComments.get(i) + if (process.env.NODE_ENV !== 'production' && !binding.targetNode) { + throw new Error('should not be undefined') + } } } } @@ -290,19 +292,15 @@ function html (tokens) { } = parseWithCache(tokens) let doUpdate - let updater let clonedDom - let clonedBoundExpressions const update = (expressions) => { - if (!updater) { - updater = createUpdater() + if (!doUpdate) { + const updater = createUpdater() clonedDom = template.cloneNode(true).content.firstElementChild - clonedBoundExpressions = cloneBoundExpressions(boundExpressions) + const clonedBoundExpressions = cloneBoundExpressions(boundExpressions) + doUpdate = updater(clonedDom, clonedBoundExpressions) } - - doUpdate = updater(clonedDom, clonedBoundExpressions) - doUpdate(expressions) return { diff --git a/src/picker/components/Picker/reactivity.js b/src/picker/components/Picker/reactivity.js index 1383126e..e6be18f7 100644 --- a/src/picker/components/Picker/reactivity.js +++ b/src/picker/components/Picker/reactivity.js @@ -2,24 +2,25 @@ export function createState () { let currentObserver const propsToObservers = new Map() - const dirtyObservers = new Set() + let dirtyObservers = new Set() let queued const flush = () => { - console.log('flush') try { - for (const observer of dirtyObservers) { + const observersToRun = dirtyObservers + dirtyObservers = new Set() // clear before running to force any new updates to run in another tick of the loop + for (const observer of observersToRun) { observer() } } finally { - dirtyObservers.clear() queued = false } } const state = new Proxy({}, { get (target, prop) { + console.log('reactivity: get', prop) if (currentObserver) { let observers = propsToObservers.get(prop) if (!observers) { @@ -31,6 +32,11 @@ export function createState () { return target[prop] }, set (target, prop, newValue) { + // if (newValue === target[prop]) { + // // unchanged, do nothing + // return true + // } + console.log('reactivity: set', prop, newValue) target[prop] = newValue const observers = propsToObservers.get(prop) if (observers) { From baf1a47b89cabe24e83e5aecc83b1fdf644a3b66 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Wed, 22 Nov 2023 07:59:37 -0800 Subject: [PATCH 034/113] fix: skip re-renders --- .../components/Picker/PickerTemplate.js | 43 ++++++------ src/picker/components/Picker/framework.js | 67 +++++++++++++------ src/picker/components/Picker/reactivity.js | 4 +- 3 files changed, 70 insertions(+), 44 deletions(-) diff --git a/src/picker/components/Picker/PickerTemplate.js b/src/picker/components/Picker/PickerTemplate.js index 4f375ae0..d41ef3ac 100644 --- a/src/picker/components/Picker/PickerTemplate.js +++ b/src/picker/components/Picker/PickerTemplate.js @@ -1,41 +1,44 @@ import { html as frameworkHtml } from './framework.js' -let domInstances = new WeakMap() +const domInstancesCache = new WeakMap() +const unkeyedSymbol = Symbol('un-keyed') + +function getFromMap (cache, key, func) { + let cached = cache.get(key) + if (!cached) { + cached = func() + cache.set(key, cached) + // console.log('creating new') + } else { + // console.log('using cached') + } + return cached +} export function createRootDom (state, helpers, events) { const { labelWithSkin, titleForEmoji, unicodeWithSkin } = helpers + const domInstances = getFromMap(domInstancesCache, state, () => new WeakMap()) + let iteratorKey = unkeyedSymbol + function html (tokens, ...expressions) { - let domInstance = domInstances.get(tokens) - if (!domInstance) { - console.log('creating new domInstance for tokens', tokens) - domInstance = frameworkHtml(tokens) - domInstances.set(tokens, domInstance) - } else { - console.log('using cached domInstance for tokens', tokens) - } + const domInstancesForTokens = getFromMap(domInstances, tokens, () => new Map()) + const domInstance = getFromMap(domInstancesForTokens, iteratorKey, () => frameworkHtml(tokens)) + const { update } = domInstance const { dom } = update(expressions) return { dom } } function map (array, callback, keyFunction) { - const existingDomInstances = domInstances - const keysToDomInstances = new Map() + const originalCacheKey = iteratorKey try { return array.map((item, index) => { - const key = keyFunction(item) - - let cached = keysToDomInstances.get(key) - if (!cached) { - cached = new WeakMap() - keysToDomInstances.set(key, cached) - } - domInstances = cached + iteratorKey = keyFunction(item) return callback(item, index) }) } finally { - domInstances = existingDomInstances + iteratorKey = originalCacheKey } } diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index 2fada4da..57def353 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -65,6 +65,50 @@ function parseTemplate (htmlString) { return template } +function patchChildren (newChildren, binding) { + const { targetNode, iteratorEndNode } = binding + const { parentNode } = targetNode + + let needsRerender = false + + if (iteratorEndNode) { // already rendered once + // check if the old children and new children are the same + let currentSibling = targetNode.nextSibling + let i = -1 + let hasOldChildren + while (!(currentSibling.nodeType === Node.COMMENT_NODE && currentSibling.textContent === 'end')) { + hasOldChildren = true + const oldSibling = currentSibling + currentSibling = currentSibling.nextSibling + const newChild = newChildren[++i] + if (!(newChild && newChild.dom === oldSibling)) { + needsRerender = true + oldSibling.remove() + } + } + if (!hasOldChildren) { + needsRerender = true + } + } else { // first render of list + needsRerender = true + const iteratorEndNode = document.createComment('end') + parentNode.insertBefore(iteratorEndNode, targetNode.nextSibling) + binding.iteratorEndNode = iteratorEndNode + } + if (needsRerender) { + for (const subExpression of newChildren) { + if (subExpression && subExpression.dom) { // html tag template itself + parentNode.insertBefore(subExpression.dom, iteratorEndNode) + } else { // primitive - string, number, etc + const textNode = document.createTextNode(toString(subExpression)) + parentNode.insertBefore(textNode, iteratorEndNode) + } + } + } else { + console.log('skipping re-render of unchanged list') + } +} + function createUpdater () { return (dom, boundExpressions) => { traverseAndSetupBindings(dom, boundExpressions) @@ -96,28 +140,7 @@ function createUpdater () { } else { // text node / dom node replacement let newNode if (expression && Array.isArray(expression)) { // array of html tag templates - const { parentNode } = targetNode - if (binding.iteratorEndNode) { // already rendered once - clean up - let currentSibling = targetNode.nextSibling - while (!(currentSibling.nodeType === Node.COMMENT_NODE && currentSibling.textContent === 'end')) { - const oldSibling = currentSibling - currentSibling = currentSibling.nextSibling - oldSibling.remove() - } - } else { // first render of list - const iteratorEndNode = document.createComment('end') - parentNode.insertBefore(iteratorEndNode, targetNode.nextSibling) - binding.iteratorEndNode = iteratorEndNode - } - const { iteratorEndNode } = binding - for (const subExpression of expression) { - if (subExpression && subExpression.dom) { // html tag template itself - parentNode.insertBefore(subExpression.dom, iteratorEndNode) - } else { // primitive - string, number, etc - const textNode = document.createTextNode(toString(subExpression)) - parentNode.insertBefore(textNode, iteratorEndNode) - } - } + patchChildren(expression, binding) } else if (expression && expression.dom) { // html tag template itself newNode = expression.dom targetNode.replaceWith(newNode) diff --git a/src/picker/components/Picker/reactivity.js b/src/picker/components/Picker/reactivity.js index e6be18f7..156bf438 100644 --- a/src/picker/components/Picker/reactivity.js +++ b/src/picker/components/Picker/reactivity.js @@ -20,7 +20,7 @@ export function createState () { const state = new Proxy({}, { get (target, prop) { - console.log('reactivity: get', prop) + // console.log('reactivity: get', prop) if (currentObserver) { let observers = propsToObservers.get(prop) if (!observers) { @@ -36,7 +36,7 @@ export function createState () { // // unchanged, do nothing // return true // } - console.log('reactivity: set', prop, newValue) + // console.log('reactivity: set', prop, newValue) target[prop] = newValue const observers = propsToObservers.get(prop) if (observers) { From 653b16a8dda8c8a394831bad99dd02db323b44a2 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Wed, 22 Nov 2023 08:08:23 -0800 Subject: [PATCH 035/113] fix: more optimizations --- src/picker/components/Picker/Picker.js | 21 +++++++++------------ src/picker/components/Picker/framework.js | 11 ++++++----- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index 3d63a99d..6547c0fe 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -134,17 +134,22 @@ export function createRoot (target, props) { let renderedRootNode let refs + let firstRender = true createEffect(() => { const { refs: theRefs, rootNode } = createRootDom(state, helpers, events) - if (renderedRootNode) { - renderedRootNode.replaceWith(rootNode) - } renderedRootNode = rootNode - target.appendChild(rootNode) refs = theRefs + if (firstRender) { + firstRender = false + target.appendChild(rootNode) + + // on first render, set up the ResizeObserver + const calculator = calculateEmojiGridStyle(refs.emojiGrid) + destroyCallbacks.push(calculator.destroy) + } }) // @@ -323,14 +328,6 @@ export function createRoot (target, props) { }) } - // calculate the width and clean up on destroy - // createEffect(() => { - // if (refs && refs.emojiGrid) { - // const calculator = calculateEmojiGridStyle(refs.emojiGrid) - // destroyCallbacks.push(calculator.destroy) - // } - // }) - // // Set or update the currentEmojis. Check for invalid ZWJ renderings // (i.e. double emoji). diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index 57def353..83606e1d 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -83,10 +83,10 @@ function patchChildren (newChildren, binding) { const newChild = newChildren[++i] if (!(newChild && newChild.dom === oldSibling)) { needsRerender = true - oldSibling.remove() + parentNode.removeChild(oldSibling) } } - if (!hasOldChildren) { + if (!hasOldChildren && newChildren.length) { // old array was empty, new array is not needsRerender = true } } else { // first render of list @@ -95,6 +95,7 @@ function patchChildren (newChildren, binding) { parentNode.insertBefore(iteratorEndNode, targetNode.nextSibling) binding.iteratorEndNode = iteratorEndNode } + // avoid re-rendering list if the dom nodes are exactly the same before and after if (needsRerender) { for (const subExpression of newChildren) { if (subExpression && subExpression.dom) { // html tag template itself @@ -104,8 +105,6 @@ function patchChildren (newChildren, binding) { parentNode.insertBefore(textNode, iteratorEndNode) } } - } else { - console.log('skipping re-render of unchanged list') } } @@ -143,7 +142,9 @@ function createUpdater () { patchChildren(expression, binding) } else if (expression && expression.dom) { // html tag template itself newNode = expression.dom - targetNode.replaceWith(newNode) + if (newNode !== targetNode) { + targetNode.replaceWith(newNode) + } } else { // primitive - string, number, etc if (targetNode.nodeType === Node.TEXT_NODE) { // already transformed into a text node targetNode.nodeValue = toString(expression) From 280a1c1f17139d43abc38b637709ab522ce83699 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Wed, 22 Nov 2023 08:09:12 -0800 Subject: [PATCH 036/113] fix: more optimizations --- src/picker/components/Picker/framework.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index 83606e1d..40ddad3c 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -72,7 +72,6 @@ function patchChildren (newChildren, binding) { let needsRerender = false if (iteratorEndNode) { // already rendered once - // check if the old children and new children are the same let currentSibling = targetNode.nextSibling let i = -1 let hasOldChildren @@ -81,6 +80,7 @@ function patchChildren (newChildren, binding) { const oldSibling = currentSibling currentSibling = currentSibling.nextSibling const newChild = newChildren[++i] + // check if the old children and new children are the same if (!(newChild && newChild.dom === oldSibling)) { needsRerender = true parentNode.removeChild(oldSibling) From 8572c4bb8430907c1ba16ee1f04ffdf0318e4d03 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Wed, 22 Nov 2023 08:22:32 -0800 Subject: [PATCH 037/113] fix: try to fix infinite loop --- src/picker/components/Picker/Picker.js | 2 ++ src/picker/components/Picker/reactivity.js | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index 6547c0fe..c08e6090 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -250,10 +250,12 @@ export function createRoot (target, props) { }) createEffect(() => { + console.log('setting skinTones') state.skinTones = Array(NUM_SKIN_TONES).fill().map((_, i) => applySkinTone(state.skinToneEmoji, i)) }) createEffect(() => { + console.log('setting skinToneButtonText') state.skinToneButtonText = state.skinTones[state.currentSkinTone] }) diff --git a/src/picker/components/Picker/reactivity.js b/src/picker/components/Picker/reactivity.js index 156bf438..cdda42a8 100644 --- a/src/picker/components/Picker/reactivity.js +++ b/src/picker/components/Picker/reactivity.js @@ -15,6 +15,10 @@ export function createState () { } } finally { queued = false + // if (dirtyObservers.size) { // new updates, queue another one + // queued = true + // flush() + // } } } From a61a7d43f835d5e0e0e50dcbbaae7b2ba207443d Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Wed, 22 Nov 2023 18:02:01 -0800 Subject: [PATCH 038/113] fix: infinite loop --- src/picker/components/Picker/Picker.js | 21 ++++++++++++--------- src/picker/components/Picker/reactivity.js | 8 ++++---- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index c08e6090..0b1dcc77 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -71,7 +71,8 @@ export function createRoot (target, props) { currentGroupIndex: 0, groups: defaultGroups, databaseLoaded: false, - activeSearchItemId: undefined + activeSearchItemId: undefined, + filteredEmojis: [] }) // @@ -365,14 +366,16 @@ export function createRoot (target, props) { // We want to treat these the same as unsupported emojis, so we compare their // widths against the baseline widths and remove them as necessary createEffect(() => { - const zwjEmojisToCheck = state.currentEmojis + const { currentEmojis, emojiVersion } = state + const zwjEmojisToCheck = currentEmojis .filter(emoji => emoji.unicode) // filter custom emoji .filter(emoji => hasZwj(emoji) && !supportedZwjEmojis.has(emoji.unicode)) - if (!state.emojiVersion && zwjEmojisToCheck.length) { + if (!emojiVersion && zwjEmojisToCheck.length) { // render now, check their length later + state.filteredEmojis = currentEmojis requestAnimationFrame(() => checkZwjSupportAndUpdate(zwjEmojisToCheck)) } else { - state.currentEmojis = state.emojiVersion ? state.currentEmojis : state.currentEmojis.filter(isZwjSupported) + state.filteredEmojis = emojiVersion ? currentEmojis : currentEmojis.filter(isZwjSupported) // Reset scroll top to 0 when emojis change requestAnimationFrame(() => resetScrollTopIfPossible(refs.tabpanelElement)) } @@ -383,8 +386,7 @@ export function createRoot (target, props) { const emojiToDomNode = emoji => shadowRootNode.getElementById(`emo-${emoji.id}`) checkZwjSupport(zwjEmojisToCheck, refs.baselineEmoji, emojiToDomNode) // force update - const { currentEmojis } = state - state.currentEmojis = currentEmojis + state.filteredEmojis = state.currentEmojis } function isZwjSupported (emoji) { @@ -430,16 +432,17 @@ export function createRoot (target, props) { createEffect(() => { function calculateCurrentEmojisWithCategories () { - if (state.searchMode) { + const { searchMode, filteredEmojis } = state + if (searchMode) { return [ { category: '', - emojis: state.currentEmojis + emojis: filteredEmojis } ] } const categoriesToEmoji = new Map() - for (const emoji of state.currentEmojis) { + for (const emoji of filteredEmojis) { const category = emoji.category || '' let emojis = categoriesToEmoji.get(category) if (!emojis) { diff --git a/src/picker/components/Picker/reactivity.js b/src/picker/components/Picker/reactivity.js index cdda42a8..3b4d3839 100644 --- a/src/picker/components/Picker/reactivity.js +++ b/src/picker/components/Picker/reactivity.js @@ -15,10 +15,10 @@ export function createState () { } } finally { queued = false - // if (dirtyObservers.size) { // new updates, queue another one - // queued = true - // flush() - // } + if (dirtyObservers.size) { // new updates, queue another one + queued = true + Promise.resolve().then(flush) + } } } From 5ed4058df742460dde70bd8effd35aa09744439a Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Wed, 22 Nov 2023 18:26:52 -0800 Subject: [PATCH 039/113] perf: minify html literals --- package.json | 2 +- rollup.config.js | 11 ++ .../components/Picker/PickerTemplate.js | 4 +- src/picker/components/Picker/framework.js | 4 +- yarn.lock | 134 +++++++++++++++++- 5 files changed, 146 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index eb3c9a0a..7ef565cd 100644 --- a/package.json +++ b/package.json @@ -103,9 +103,9 @@ "jest-environment-jsdom": "^29.7.0", "lint-staged": "^15.1.0", "lodash-es": "^4.17.15", - "magic-string": "^0.30.5", "markdown-table": "^3.0.2", "markdown-toc": "^1.2.0", + "minify-html-literals": "^1.3.5", "npm-run-all": "^4.1.5", "playwright": "^1.39.0", "pretty-bytes": "^6.1.1", diff --git a/rollup.config.js b/rollup.config.js index 80251f0e..9d5709c9 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -4,6 +4,7 @@ import replace from '@rollup/plugin-replace' import strip from '@rollup/plugin-strip' import preprocess from 'svelte-preprocess' import analyze from 'rollup-plugin-analyzer' +import { minifyHTMLLiterals } from 'minify-html-literals' import { buildStyles } from './bin/buildStyles.js' const { NODE_ENV, DEBUG } = process.env @@ -40,6 +41,16 @@ const baseConfig = { delimiters: ['', ''], preventAssignment: true }), + { + name: 'minify-html-in-tag-template-literals', + transform (content, id) { + if (id.includes('PickerTemplate.js')) { + // minify the html so that the output is smaller + const { code, map } = minifyHTMLLiterals(content) + return { code, map } + } + } + }, strip({ include: ['**/*.js'], functions: [ diff --git a/src/picker/components/Picker/PickerTemplate.js b/src/picker/components/Picker/PickerTemplate.js index d41ef3ac..8da957a6 100644 --- a/src/picker/components/Picker/PickerTemplate.js +++ b/src/picker/components/Picker/PickerTemplate.js @@ -1,4 +1,4 @@ -import { html as frameworkHtml } from './framework.js' +import { parseHtml } from './framework.js' const domInstancesCache = new WeakMap() const unkeyedSymbol = Symbol('un-keyed') @@ -23,7 +23,7 @@ export function createRootDom (state, helpers, events) { function html (tokens, ...expressions) { const domInstancesForTokens = getFromMap(domInstances, tokens, () => new Map()) - const domInstance = getFromMap(domInstancesForTokens, iteratorKey, () => frameworkHtml(tokens)) + const domInstance = getFromMap(domInstancesForTokens, iteratorKey, () => parseHtml(tokens)) const { update } = domInstance const { dom } = update(expressions) diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index 40ddad3c..2f3c55c6 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -309,7 +309,7 @@ function traverseAndSetupBindings (dom, boundExpressions) { } while ((element = treeWalker.nextNode())) } -function html (tokens) { +function parseHtml (tokens) { const { template, boundExpressions @@ -338,5 +338,5 @@ function html (tokens) { } export { - html + parseHtml } diff --git a/yarn.lock b/yarn.lock index 4593d0c5..03662e7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -955,6 +955,14 @@ dependencies: "@babel/types" "^7.20.7" +"@types/clean-css@*": + version "4.2.11" + resolved "https://registry.yarnpkg.com/@types/clean-css/-/clean-css-4.2.11.tgz#3f170dedd8d096fe7e7bd1c8dda0c8314217cbe6" + integrity sha512-Y8n81lQVTAfP2TOdtJJEsCoYl1AnOkqDqMvXb9/7pfgZZ7r8YrEyurrAvAoAjHOGXKRybay+5CsExqIH6liccw== + dependencies: + "@types/node" "*" + source-map "^0.6.0" + "@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.1": version "1.0.2" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.2.tgz#ff02bc3dc8317cd668dfec247b750ba1f1d62453" @@ -974,6 +982,15 @@ dependencies: "@types/node" "*" +"@types/html-minifier@^3.5.3": + version "3.5.3" + resolved "https://registry.yarnpkg.com/@types/html-minifier/-/html-minifier-3.5.3.tgz#5276845138db2cebc54c789e0aaf87621a21e84f" + integrity sha512-j1P/4PcWVVCPEy5lofcHnQ6BtXz9tHGiFPWzqm7TtGuWZEfCHEP446HlkSNc9fQgNJaJZ6ewPtp2aaFla/Uerg== + dependencies: + "@types/clean-css" "*" + "@types/relateurl" "*" + "@types/uglify-js" "*" + "@types/http-cache-semantics@^4.0.2": version "4.0.2" resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.2.tgz#abe102d06ccda1efdf0ed98c10ccf7f36a785a41" @@ -1042,6 +1059,11 @@ resolved "https://registry.yarnpkg.com/@types/pug/-/pug-2.0.7.tgz#ffb9239e4da7ea1af27070cad9343049e440993d" integrity sha512-I469DU0UXNC1aHepwirWhu9YKg5fkxohZD95Ey/5A7lovC+Siu+MCLffva87lnfThaOrw9Vb1DUN5t55oULAAw== +"@types/relateurl@*": + version "0.2.33" + resolved "https://registry.yarnpkg.com/@types/relateurl/-/relateurl-0.2.33.tgz#fa174c30100d91e88d7b0ba60cefd7e8c532516f" + integrity sha512-bTQCKsVbIdzLqZhLkF5fcJQreE4y1ro4DIyVrlDNSCJRRwHhB8Z+4zXXa8jN6eDvc2HbRsEYgbvrnGvi54EpSw== + "@types/resolve@1.20.2": version "1.20.2" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975" @@ -1062,6 +1084,13 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.4.tgz#2b38784cd16957d3782e8e2b31c03bc1d13b4d65" integrity sha512-IDaobHimLQhjwsQ/NMwRVfa/yL7L/wriQPMhw1ZJall0KX6E1oxk29XMDeilW5qTIg5aoiqf5Udy8U/51aNoQQ== +"@types/uglify-js@*": + version "3.17.4" + resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.17.4.tgz#3c70021f08023e5a760ce133d22966f200e1d31c" + integrity sha512-Hm/T0kV3ywpJyMGNbsItdivRhYNCQQf1IIsYsXnoVPES4t+FMLyDe0/K+Ea7ahWtMtSNb22ZdY7MIyoD9rqARg== + dependencies: + source-map "^0.6.1" + "@types/yargs-parser@*": version "21.0.1" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.1.tgz#07773d7160494d56aa882d7531aac7319ea67c3b" @@ -1667,6 +1696,14 @@ callsites@^3.0.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== +camel-case@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73" + integrity sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w== + dependencies: + no-case "^2.2.0" + upper-case "^1.1.1" + camelcase-keys@^6.2.2: version "6.2.2" resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-6.2.2.tgz#5e755d6ba51aa223ec7d3d52f25778210f9dc3c0" @@ -1769,6 +1806,13 @@ cjs-module-lexer@^1.0.0: resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz#6c370ab19f8a3394e318fe682686ec0ac684d107" integrity sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ== +clean-css@^4.2.1: + version "4.2.4" + resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.4.tgz#733bf46eba4e607c6891ea57c24a989356831178" + integrity sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A== + dependencies: + source-map "~0.6.0" + cli-cursor@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-4.0.0.tgz#3cecfe3734bf4fe02a8361cbdc0f6fe28c6a57ea" @@ -1893,7 +1937,7 @@ commander@11.1.0: resolved "https://registry.yarnpkg.com/commander/-/commander-11.1.0.tgz#62fdce76006a68e5c1ab3314dc92e800eb83d906" integrity sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ== -commander@^2.20.0: +commander@^2.19.0, commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -3801,6 +3845,11 @@ has@^1.0.3: resolved "https://registry.yarnpkg.com/has/-/has-1.0.4.tgz#2eb2860e000011dae4f1406a86fe80e530fb2ec6" integrity sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ== +he@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + hosted-git-info@^2.1.4: version "2.8.9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" @@ -3825,6 +3874,19 @@ html-escaper@^2.0.0: resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== +html-minifier@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-4.0.0.tgz#cca9aad8bce1175e02e17a8c33e46d8988889f56" + integrity sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig== + dependencies: + camel-case "^3.0.0" + clean-css "^4.2.1" + commander "^2.19.0" + he "^1.2.0" + param-case "^2.1.1" + relateurl "^0.2.7" + uglify-js "^3.5.1" + html-tags@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce" @@ -5428,6 +5490,11 @@ loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" +lower-case@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac" + integrity sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA== + lowercase-keys@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-3.0.0.tgz#c5e7d442e37ead247ae9db117a9d0a467c89d4f2" @@ -5457,6 +5524,13 @@ lz-string@^1.5.0: resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== +magic-string@^0.25.0: + version "0.25.9" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c" + integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ== + dependencies: + sourcemap-codec "^1.4.8" + magic-string@^0.27.0: version "0.27.0" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.27.0.tgz#e4a3413b4bab6d98d2becffd48b4a257effdbbf3" @@ -5464,7 +5538,7 @@ magic-string@^0.27.0: dependencies: "@jridgewell/sourcemap-codec" "^1.4.13" -magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5: +magic-string@^0.30.3, magic-string@^0.30.4: version "0.30.5" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.5.tgz#1994d980bd1c8835dc6e78db7cbd4ae4f24746f9" integrity sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA== @@ -5658,6 +5732,17 @@ min-indent@^1.0.0, min-indent@^1.0.1: resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== +minify-html-literals@^1.3.5: + version "1.3.5" + resolved "https://registry.yarnpkg.com/minify-html-literals/-/minify-html-literals-1.3.5.tgz#11c05e2b9699be7f41647186a9fe8249b7de6734" + integrity sha512-p8T8ryePRR8FVfJZLVFmM53WY25FL0moCCTycUDuAu6rf9GMLwy0gNjXBGNin3Yun7Y+tIWd28axOf0t2EpAlQ== + dependencies: + "@types/html-minifier" "^3.5.3" + clean-css "^4.2.1" + html-minifier "^4.0.0" + magic-string "^0.25.0" + parse-literals "^1.2.1" + minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -5761,6 +5846,13 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +no-case@^2.2.0: + version "2.3.2" + resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac" + integrity sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ== + dependencies: + lower-case "^1.1.1" + node-fetch@^2.6.12: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" @@ -6102,6 +6194,13 @@ pako@~1.0.2: resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== +param-case@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/param-case/-/param-case-2.1.1.tgz#df94fd8cf6531ecf75e6bef9a0858fbc72be2247" + integrity sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w== + dependencies: + no-case "^2.2.0" + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -6127,6 +6226,13 @@ parse-json@^5.0.0, parse-json@^5.2.0: json-parse-even-better-errors "^2.3.0" lines-and-columns "^1.1.6" +parse-literals@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/parse-literals/-/parse-literals-1.2.1.tgz#2311855a12a6e12434f44eb40fa434c48cc0560f" + integrity sha512-Ml0w104Ph2wwzuRdxrg9booVWsngXbB4bZ5T2z6WyF8b5oaNkUmBiDtahi34yUIpXD8Y13JjAK6UyIyApJ73RQ== + dependencies: + typescript "^2.9.2 || ^3.0.0 || ^4.0.0" + parse5@^5.1.0: version "5.1.1" resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" @@ -6731,6 +6837,11 @@ regexpp@^3.0.0: resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== +relateurl@^0.2.7: + version "0.2.7" + resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" + integrity sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog== + remarkable@^1.7.1: version "1.7.4" resolved "https://registry.yarnpkg.com/remarkable/-/remarkable-1.7.4.tgz#19073cb960398c87a7d6546eaa5e50d2022fcd00" @@ -7210,11 +7321,16 @@ source-map-support@^0.5.16, source-map-support@~0.5.20: buffer-from "^1.0.0" source-map "^0.6.0" -source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: +source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +sourcemap-codec@^1.4.8: + version "1.4.8" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" + integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== + spdx-correct@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.2.0.tgz#4f5ab0668f0059e34f9c00dce331784a12de4e9c" @@ -8008,6 +8124,11 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== +"typescript@^2.9.2 || ^3.0.0 || ^4.0.0": + version "4.9.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" + integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== + typical@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4" @@ -8023,7 +8144,7 @@ ua-parser-js@^1.0.2: resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.36.tgz#a9ab6b9bd3a8efb90bb0816674b412717b7c428c" integrity sha512-znuyCIXzl8ciS3+y3fHJI/2OhQIXbXw9MWC/o3qwyR+RGppjZHrM27CGFSKCJXi2Kctiz537iOu2KnXs1lMQhw== -uglify-js@^3.1.4: +uglify-js@^3.1.4, uglify-js@^3.5.1: version "3.17.4" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.4.tgz#61678cf5fa3f5b7eb789bb345df29afb8257c22c" integrity sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g== @@ -8074,6 +8195,11 @@ update-browserslist-db@^1.0.13: escalade "^3.1.1" picocolors "^1.0.0" +upper-case@^1.1.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598" + integrity sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA== + uri-js@^4.2.2: version "4.4.1" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" From acd28479b41d3361302a2a034e221d2873840664 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Wed, 22 Nov 2023 18:31:56 -0800 Subject: [PATCH 040/113] perf: dedupe emojiList as snippet --- .../components/Picker/PickerTemplate.js | 63 +++++++------------ 1 file changed, 21 insertions(+), 42 deletions(-) diff --git a/src/picker/components/Picker/PickerTemplate.js b/src/picker/components/Picker/PickerTemplate.js index 8da957a6..9872cb66 100644 --- a/src/picker/components/Picker/PickerTemplate.js +++ b/src/picker/components/Picker/PickerTemplate.js @@ -42,6 +42,25 @@ export function createRootDom (state, helpers, events) { } } + function emojiList (emojis, searchMode, prefix) { + return map(emojis, (emoji, i) => { + return html` + + ` + }, emoji => `${prefix}-${emoji.id}`) // unique by prefix so it doesn't collide with different `emojiList()`s + } + const section = () => { return html`
${ - (() => { - const emojis = emojiWithCategory.emojis - const searchMode = state.searchMode - const prefix = 'emo' - return map(emojis, (emoji, i) => { - return html` - - ` - }, emoji => emoji.id) - })() + emojiList(emojiWithCategory.emojis, state.searchMode, /* prefix */ 'emo') } @@ -243,27 +242,7 @@ export function createRootDom (state, helpers, events) { style="padding-inline-end: ${state.scrollbarWidth}px" data-on-click="onEmojiClick"> ${ - (() => { - const emojis = state.currentFavorites - const searchMode = false - const prefix = 'fav' - return map(emojis, (emoji, i) => { - return html` - - ` - }, emoji => emoji.id) - })() + emojiList(state.currentFavorites, /* searchMode */ false, /* prefix */ 'fav') } From 5be3bcd5ff89cbb65c492709281480bdad97ea6a Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Wed, 22 Nov 2023 18:35:02 -0800 Subject: [PATCH 041/113] fix: remove svelte and reduce bundlesize test --- bin/bundlesize.js | 4 +- config/svelte.config.js | 6 -- jest.config.cjs | 9 +-- package.json | 7 +- rollup.config.js | 15 ---- shims/svelte-v3-shim.js | 9 --- yarn.lock | 162 +++------------------------------------- 7 files changed, 14 insertions(+), 198 deletions(-) delete mode 100644 config/svelte.config.js delete mode 100644 shims/svelte-v3-shim.js diff --git a/bin/bundlesize.js b/bin/bundlesize.js index 02510106..314d2f49 100644 --- a/bin/bundlesize.js +++ b/bin/bundlesize.js @@ -5,8 +5,8 @@ import { promisify } from 'node:util' import prettyBytes from 'pretty-bytes' import fs from 'node:fs/promises' -const MAX_SIZE_MIN = '53 kB' -const MAX_SIZE_MINGZ = '19 kB' +const MAX_SIZE_MIN = '37 kB' +const MAX_SIZE_MINGZ = '13 kB' const FILENAME = './bundle.js' diff --git a/config/svelte.config.js b/config/svelte.config.js deleted file mode 100644 index 95df2bd7..00000000 --- a/config/svelte.config.js +++ /dev/null @@ -1,6 +0,0 @@ -import preprocess from 'svelte-preprocess' - -export default { - preprocess: preprocess(), - emitCss: false -} diff --git a/jest.config.cjs b/jest.config.cjs index 1b25ba8a..96959a80 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -3,14 +3,7 @@ module.exports = { testMatch: [ '/test/spec/**/*.{spec,test}.{js,jsx,ts,tsx}' ], - transform: { - '^.+\\.svelte$': ['svelte-jester', { - preprocess: './config/svelte.config.js', - compilerOptions: { - dev: false - } - }] - }, + transform: {}, moduleFileExtensions: ['js', 'svelte'], extensionsToTreatAsEsm: ['.svelte'], testPathIgnorePatterns: ['node_modules'], diff --git a/package.json b/package.json index 7ef565cd..3cff0098 100644 --- a/package.json +++ b/package.json @@ -64,8 +64,7 @@ "custom", "element", "web", - "component", - "svelte" + "component" ], "author": "Nolan Lawson ", "license": "Apache-2.0", @@ -113,7 +112,6 @@ "recursive-readdir": "^2.2.3", "rollup": "^4.3.1", "rollup-plugin-analyzer": "^4.0.0", - "rollup-plugin-svelte": "^7.1.6", "rollup-plugin-terser": "^7.0.2", "sass": "^1.69.5", "shx": "^0.3.4", @@ -122,9 +120,6 @@ "stylelint": "^15.11.0", "stylelint-config-recommended-scss": "^13.1.0", "stylelint-scss": "^5.3.1", - "svelte": "^5.0.0-next.2", - "svelte-jester": "^3.0.0", - "svelte-preprocess": "^5.1.0", "svgo": "^3.0.3", "tachometer": "^0.7.0", "terser": "^5.24.0" diff --git a/rollup.config.js b/rollup.config.js index 9d5709c9..20ca847c 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -2,7 +2,6 @@ import cjs from '@rollup/plugin-commonjs' import resolve from '@rollup/plugin-node-resolve' import replace from '@rollup/plugin-replace' import strip from '@rollup/plugin-strip' -import preprocess from 'svelte-preprocess' import analyze from 'rollup-plugin-analyzer' import { minifyHTMLLiterals } from 'minify-html-literals' import { buildStyles } from './bin/buildStyles.js' @@ -10,20 +9,6 @@ import { buildStyles } from './bin/buildStyles.js' const { NODE_ENV, DEBUG } = process.env const dev = NODE_ENV !== 'production' -const preprocessConfig = preprocess() - -const origMarkup = preprocessConfig.markup -// minify the HTML by removing extra whitespace -// TODO: this is fragile, but it also results in a lot of bundlesize savings. let's find a better solution -preprocessConfig.markup = async function () { - const res = await origMarkup.apply(this, arguments) - - // remove whitespace - res.code = res.code.replace(/([>}])\s+([<{])/sg, '$1$2') - - return res -} - // Build Database.test.js and Picker.js as separate modules at build times so that they are properly tree-shakeable. // Most of this has to happen because customElements.define() has side effects const baseConfig = { diff --git a/shims/svelte-v3-shim.js b/shims/svelte-v3-shim.js deleted file mode 100644 index b04a9b3d..00000000 --- a/shims/svelte-v3-shim.js +++ /dev/null @@ -1,9 +0,0 @@ -// TODO: drop Svelte v3 support -// ensure_array_like was added in Svelte v4 - we shim it to avoid breaking Svelte v3 users -// this code is copied from svelte v4 -/* eslint-disable camelcase */ -export function ensure_array_like_shim (array_like_or_iterator) { - return array_like_or_iterator?.length !== undefined - ? array_like_or_iterator - : Array.from(array_like_or_iterator) -} diff --git a/yarn.lock b/yarn.lock index 03662e7b..cd960426 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,7 +12,7 @@ resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.1.tgz#abfccb8ca78075a2b6187345c26243c1a0842f28" integrity sha512-/62yikz7NLScCGAAST5SHdnjaDJQBDq0M2muyRTpf2VQhw6StBg2ALiu73zSJQ4fMVLA+0uBhBHAle7Wg+2kSg== -"@ampproject/remapping@^2.2.0", "@ampproject/remapping@^2.2.1": +"@ampproject/remapping@^2.2.0": version "2.2.1" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.1.tgz#99e8e11851128b8702cd57c33684f1d0f260b630" integrity sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg== @@ -625,7 +625,7 @@ "@jridgewell/gen-mapping" "^0.3.0" "@jridgewell/trace-mapping" "^0.3.9" -"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.13", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.4.15": +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.4.15": version "1.4.15" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== @@ -761,14 +761,6 @@ estree-walker "^2.0.2" magic-string "^0.30.3" -"@rollup/pluginutils@^4.1.0": - version "4.2.1" - resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d" - integrity sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ== - dependencies: - estree-walker "^2.0.1" - picomatch "^2.2.2" - "@rollup/pluginutils@^5.0.1": version "5.0.5" resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.0.5.tgz#bbb4c175e19ebfeeb8c132c2eea0ecb89941a66c" @@ -963,7 +955,7 @@ "@types/node" "*" source-map "^0.6.0" -"@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.1": +"@types/estree@*", "@types/estree@^1.0.0": version "1.0.2" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.2.tgz#ff02bc3dc8317cd668dfec247b750ba1f1d62453" integrity sha512-VeiPZ9MMwXjO32/Xu7+OwflfmeoRwkE/qzndw42gGtgJwZopBnzy2gD//NN1+go1mADzkDcqf/KnFRSjTJ8xJA== @@ -1054,11 +1046,6 @@ resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-5.0.3.tgz#e7b5aebbac150f8b5fdd4a46e7f0bd8e65e19109" integrity sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw== -"@types/pug@^2.0.6": - version "2.0.7" - resolved "https://registry.yarnpkg.com/@types/pug/-/pug-2.0.7.tgz#ffb9239e4da7ea1af27070cad9343049e440993d" - integrity sha512-I469DU0UXNC1aHepwirWhu9YKg5fkxohZD95Ey/5A7lovC+Siu+MCLffva87lnfThaOrw9Vb1DUN5t55oULAAw== - "@types/relateurl@*": version "0.2.33" resolved "https://registry.yarnpkg.com/@types/relateurl/-/relateurl-0.2.33.tgz#fa174c30100d91e88d7b0ba60cefd7e8c532516f" @@ -1149,7 +1136,7 @@ acorn-walk@^8.0.2: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== -acorn@^8.1.0, acorn@^8.10.0, acorn@^8.8.1, acorn@^8.8.2, acorn@^8.9.0: +acorn@^8.1.0, acorn@^8.8.1, acorn@^8.8.2, acorn@^8.9.0: version "8.10.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5" integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw== @@ -1287,7 +1274,7 @@ aria-query@5.1.3: dependencies: deep-equal "^2.0.5" -aria-query@^5.0.0, aria-query@^5.3.0: +aria-query@^5.0.0: version "5.3.0" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== @@ -1448,13 +1435,6 @@ available-typed-arrays@^1.0.5: resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== -axobject-query@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.0.0.tgz#04a4c90dce33cc5d606c76d6216e3b250ff70dab" - integrity sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw== - dependencies: - dequal "^2.0.3" - b4a@^1.6.4: version "1.6.4" resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.6.4.tgz#ef1c1422cae5ce6535ec191baeed7567443f36c9" @@ -1612,7 +1592,7 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" -buffer-crc32@^0.2.5, buffer-crc32@~0.2.3: +buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== @@ -2532,11 +2512,6 @@ destroy@1.2.0, destroy@^1.0.4: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== -detect-indent@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.1.0.tgz#592485ebbbf6b3b1ab2be175c8393d04ca0d57e6" - integrity sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA== - detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" @@ -2803,11 +2778,6 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -es6-promise@^3.1.2: - version "3.3.1" - resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" - integrity sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg== - escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" @@ -3022,11 +2992,6 @@ eslint@^8.41.0: strip-ansi "^6.0.1" text-table "^0.2.0" -esm-env@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/esm-env/-/esm-env-1.0.0.tgz#b124b40b180711690a4cb9b00d16573391950413" - integrity sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA== - espree@^9.6.0, espree@^9.6.1: version "9.6.1" resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" @@ -3048,14 +3013,6 @@ esquery@^1.4.2: dependencies: estraverse "^5.1.0" -esrap@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/esrap/-/esrap-1.1.1.tgz#0ecadf5f8bbb746de74f5ad9e47ffa8f4cb593e3" - integrity sha512-PIgHGLP8VAG4Iao4CbOc+/5tgn+TpzGhyAuVCR5qgcFgPOUk9Ds61bH7hD2lbjDuu86lagofx3lVrRFWcIF+Gg== - dependencies: - "@jridgewell/sourcemap-codec" "^1.4.15" - "@types/estree" "^1.0.1" - esrecurse@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" @@ -3068,7 +3025,7 @@ estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== -estree-walker@^2.0.1, estree-walker@^2.0.2: +estree-walker@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== @@ -3754,7 +3711,7 @@ got@^12.1.0: p-cancelable "^3.0.0" responselike "^3.0.0" -graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.9: +graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -4333,13 +4290,6 @@ is-reference@1.2.1: dependencies: "@types/estree" "*" -is-reference@^3.0.1: - version "3.0.2" - resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-3.0.2.tgz#154747a01f45cd962404ee89d43837af2cba247c" - integrity sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg== - dependencies: - "@types/estree" "*" - is-regex@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" @@ -5335,11 +5285,6 @@ load-json-file@^5.2.0: strip-bom "^3.0.0" type-fest "^0.3.0" -locate-character@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/locate-character/-/locate-character-3.0.0.tgz#0305c5b8744f61028ef5d01f444009e00779f974" - integrity sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA== - locate-path@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" @@ -5531,14 +5476,7 @@ magic-string@^0.25.0: dependencies: sourcemap-codec "^1.4.8" -magic-string@^0.27.0: - version "0.27.0" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.27.0.tgz#e4a3413b4bab6d98d2becffd48b4a257effdbbf3" - integrity sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA== - dependencies: - "@jridgewell/sourcemap-codec" "^1.4.13" - -magic-string@^0.30.3, magic-string@^0.30.4: +magic-string@^0.30.3: version "0.30.5" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.5.tgz#1994d980bd1c8835dc6e78db7cbd4ae4f24746f9" integrity sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA== @@ -5789,13 +5727,6 @@ mkdirp-classic@^0.5.2: resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== -mkdirp@^0.5.1: - version "0.5.6" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" - integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== - dependencies: - minimist "^1.2.6" - modify-values@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" @@ -6322,7 +6253,7 @@ picocolors@^1.0.0: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== -picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3, picomatch@^2.3.1: +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== @@ -6953,13 +6884,6 @@ rfdc@^1.3.0: resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b" integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA== -rimraf@^2.5.2: - version "2.7.1" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" - integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== - dependencies: - glob "^7.1.3" - rimraf@^3.0.0, rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" @@ -6972,14 +6896,6 @@ rollup-plugin-analyzer@^4.0.0: resolved "https://registry.yarnpkg.com/rollup-plugin-analyzer/-/rollup-plugin-analyzer-4.0.0.tgz#96b757ed64a098b59d72f085319e68cdd86d5798" integrity sha512-LL9GEt3bkXp6Wa19SNR5MWcvHNMvuTFYg+eYBZN2OIFhSWN+pEJUQXEKu5BsOeABob3x9PDaLKW7w5iOJnsESQ== -rollup-plugin-svelte@^7.1.6: - version "7.1.6" - resolved "https://registry.yarnpkg.com/rollup-plugin-svelte/-/rollup-plugin-svelte-7.1.6.tgz#44a4ea6c6e8ed976824d9fd40c78d048515e5838" - integrity sha512-nVFRBpGWI2qUY1OcSiEEA/kjCY2+vAjO9BI8SzA7NRrh2GTunLd6w2EYmnMt/atgdg8GvcNjLsmZmbQs/u4SQA== - dependencies: - "@rollup/pluginutils" "^4.1.0" - resolve.exports "^2.0.0" - rollup-plugin-terser@^7.0.2: version "7.0.2" resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz#e8fbba4869981b2dc35ae7e8a502d5c6c04d324d" @@ -7050,16 +6966,6 @@ safe-regex-test@^1.0.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -sander@^0.5.0: - version "0.5.1" - resolved "https://registry.yarnpkg.com/sander/-/sander-0.5.1.tgz#741e245e231f07cafb6fdf0f133adfa216a502ad" - integrity sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA== - dependencies: - es6-promise "^3.1.2" - graceful-fs "^4.1.3" - mkdirp "^0.5.1" - rimraf "^2.5.2" - sanitize-filename@^1.6.3: version "1.6.3" resolved "https://registry.yarnpkg.com/sanitize-filename/-/sanitize-filename-1.6.3.tgz#755ebd752045931977e30b2025d340d7c9090378" @@ -7290,16 +7196,6 @@ socks@^2.7.1: ip "^2.0.0" smart-buffer "^4.2.0" -sorcery@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/sorcery/-/sorcery-0.11.0.tgz#310c80ee993433854bb55bb9aa4003acd147fca8" - integrity sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw== - dependencies: - "@jridgewell/sourcemap-codec" "^1.4.14" - buffer-crc32 "^0.2.5" - minimist "^1.2.0" - sander "^0.5.0" - "source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.1, source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" @@ -7714,39 +7610,6 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== -svelte-jester@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/svelte-jester/-/svelte-jester-3.0.0.tgz#7872beb559bce3c66f134d6f016f2626cf8f1d1c" - integrity sha512-V279cL906++hn00hkL1xAr/y5OjjxPYWic1g0yTJFmqdbdWKthdcuP3XBvmmwP9AzFBT51DlPgXz56HItle1Ug== - -svelte-preprocess@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/svelte-preprocess/-/svelte-preprocess-5.1.0.tgz#824a1b072da118cba46ad2e5e88a568083acad4b" - integrity sha512-EkErPiDzHAc0k2MF5m6vBNmRUh338h2myhinUw/xaqsLs7/ZvsgREiLGj03VrSzbY/TB5ZXgBOsKraFee5yceA== - dependencies: - "@types/pug" "^2.0.6" - detect-indent "^6.1.0" - magic-string "^0.27.0" - sorcery "^0.11.0" - strip-indent "^3.0.0" - -svelte@^5.0.0-next.2: - version "5.0.0-next.2" - resolved "https://registry.yarnpkg.com/svelte/-/svelte-5.0.0-next.2.tgz#302590de1e52d8aafb1199fbb393857478ec9b5e" - integrity sha512-fEB9A+KbZi+a21Baw+RbBQ49Gq35tqqKeWB2xrPV7tbKZT7E1sJMq0ZDNk72L5y29+N33FPnwH8v5LBFAD/PGw== - dependencies: - "@ampproject/remapping" "^2.2.1" - "@jridgewell/sourcemap-codec" "^1.4.15" - acorn "^8.10.0" - aria-query "^5.3.0" - axobject-query "^4.0.0" - esm-env "^1.0.0" - esrap "^1.1.1" - is-reference "^3.0.1" - locate-character "^3.0.0" - magic-string "^0.30.4" - zimmerframe "^1.1.0" - svg-tags@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764" @@ -8563,8 +8426,3 @@ yocto-queue@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== - -zimmerframe@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/zimmerframe/-/zimmerframe-1.1.0.tgz#29f2b760d11228490109808e2b56ba67f25af199" - integrity sha512-+AmV37r9NPUy7KcuG0Fde9AAFSD88kN5pnqvD7Pkp5WLLK0jct7hAtIDXXFDCRk3l5Mc1r2Sth3gfP2ZLE+/Qw== From 8ecabeceb462e0f04a5668bab90fe83fd5337c1a Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Wed, 22 Nov 2023 18:47:51 -0800 Subject: [PATCH 042/113] fix: keyboard shortcuts --- src/picker/components/Picker/Picker.js | 8 ++++---- src/picker/components/Picker/reactivity.js | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index 0b1dcc77..08f237d6 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -533,13 +533,13 @@ export function createRoot (target, props) { switch (key) { case 'ArrowLeft': - return doFocus(target.previousSibling) + return doFocus(target.previousElementSibling) case 'ArrowRight': - return doFocus(target.nextSibling) + return doFocus(target.nextElementSibling) case 'Home': - return doFocus(target.parentElement.firstChild) + return doFocus(target.parentElement.firstElementChild) case 'End': - return doFocus(target.parentElement.lastChild) + return doFocus(target.parentElement.lastElementChild) } } diff --git a/src/picker/components/Picker/reactivity.js b/src/picker/components/Picker/reactivity.js index 3b4d3839..986ee162 100644 --- a/src/picker/components/Picker/reactivity.js +++ b/src/picker/components/Picker/reactivity.js @@ -7,6 +7,7 @@ export function createState () { let queued const flush = () => { + // console.info('flush') try { const observersToRun = dirtyObservers dirtyObservers = new Set() // clear before running to force any new updates to run in another tick of the loop @@ -24,7 +25,7 @@ export function createState () { const state = new Proxy({}, { get (target, prop) { - // console.log('reactivity: get', prop) + // console.info('reactivity: get', prop) if (currentObserver) { let observers = propsToObservers.get(prop) if (!observers) { @@ -40,7 +41,7 @@ export function createState () { // // unchanged, do nothing // return true // } - // console.log('reactivity: set', prop, newValue) + // console.info('reactivity: set', prop, newValue) target[prop] = newValue const observers = propsToObservers.get(prop) if (observers) { From 6779cf31573b54f584f6e5d5257b4d27113c22e7 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Wed, 22 Nov 2023 20:14:35 -0800 Subject: [PATCH 043/113] fix: another infinite cycle --- src/picker/components/Picker/Picker.js | 10 +++++++--- src/picker/components/Picker/reactivity.js | 17 ++++++++++------- src/picker/groups.js | 3 +-- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index 08f237d6..8e189f59 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -1,5 +1,5 @@ /* eslint-disable prefer-const,no-labels,no-inner-declarations */ -import { groups as defaultGroups, customGroup } from '../../groups' +import { groups as defaultGroups, allGroups as groupsWithCustom } from '../../groups' import { MIN_SEARCH_TEXT_LENGTH, NUM_SKIN_TONES } from '../../../shared/constants' import { requestIdleCallback } from '../../utils/requestIdleCallback' import { hasZwj } from '../../utils/hasZwj' @@ -79,7 +79,9 @@ export function createRoot (target, props) { // Update the current group based on the currentGroupIndex // createEffect(() => { - state.currentGroup = state.groups[state.currentGroupIndex] + if (state.currentGroup !== state.groups[state.currentGroupIndex]) { + state.currentGroup = state.groups[state.currentGroupIndex] + } }) // @@ -225,7 +227,9 @@ export function createRoot (target, props) { createEffect(() => { if (state.customEmoji && state.customEmoji.length) { - state.groups = [customGroup, ...defaultGroups] + if (state.groups !== groupsWithCustom) { // don't update unnecessarily + state.groups = groupsWithCustom + } } else if (state.groups !== defaultGroups) { if (state.currentGroupIndex) { // If the current group is anything other than "custom" (which is first), decrement. diff --git a/src/picker/components/Picker/reactivity.js b/src/picker/components/Picker/reactivity.js index 986ee162..1f37edf9 100644 --- a/src/picker/components/Picker/reactivity.js +++ b/src/picker/components/Picker/reactivity.js @@ -6,8 +6,13 @@ export function createState () { let queued + let recursionDepth = 0 + const MAX_RECURSION_DEPTH = 30 + const flush = () => { - // console.info('flush') + if (process.env.NODE_ENV !== 'production' && recursionDepth === MAX_RECURSION_DEPTH) { + throw new Error('max recusion depth, you probably didn\'t mean to do this') + } try { const observersToRun = dirtyObservers dirtyObservers = new Set() // clear before running to force any new updates to run in another tick of the loop @@ -17,6 +22,7 @@ export function createState () { } finally { queued = false if (dirtyObservers.size) { // new updates, queue another one + recursionDepth++ queued = true Promise.resolve().then(flush) } @@ -25,7 +31,7 @@ export function createState () { const state = new Proxy({}, { get (target, prop) { - // console.info('reactivity: get', prop) + console.log('reactivity: get', prop) if (currentObserver) { let observers = propsToObservers.get(prop) if (!observers) { @@ -37,11 +43,7 @@ export function createState () { return target[prop] }, set (target, prop, newValue) { - // if (newValue === target[prop]) { - // // unchanged, do nothing - // return true - // } - // console.info('reactivity: set', prop, newValue) + console.log('reactivity: set', prop, newValue) target[prop] = newValue const observers = propsToObservers.get(prop) if (observers) { @@ -49,6 +51,7 @@ export function createState () { dirtyObservers.add(observer) } if (!queued) { + recursionDepth = 0 queued = true Promise.resolve().then(flush) } diff --git a/src/picker/groups.js b/src/picker/groups.js index 6fa8648f..c9d25532 100644 --- a/src/picker/groups.js +++ b/src/picker/groups.js @@ -1,5 +1,5 @@ // via https://unpkg.com/browse/emojibase-data@6.0.0/meta/groups.json -const allGroups = [ +export const allGroups = [ [-1, '✨', 'custom'], [0, '😀', 'smileys-emotion'], [1, '👋', 'people-body'], @@ -13,4 +13,3 @@ const allGroups = [ ].map(([id, emoji, name]) => ({ id, emoji, name })) export const groups = allGroups.slice(1) -export const customGroup = allGroups[0] From 9cfd72d03cc9aff9e93d7f1874f30eba10462108 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Wed, 22 Nov 2023 20:51:24 -0800 Subject: [PATCH 044/113] fix: fix some tests --- config/minifyHtmlInJest.js | 10 ++++++++++ jest.config.cjs | 10 +++------- 2 files changed, 13 insertions(+), 7 deletions(-) create mode 100644 config/minifyHtmlInJest.js diff --git a/config/minifyHtmlInJest.js b/config/minifyHtmlInJest.js new file mode 100644 index 00000000..f1ce79d3 --- /dev/null +++ b/config/minifyHtmlInJest.js @@ -0,0 +1,10 @@ +import { minifyHTMLLiterals } from 'minify-html-literals' + +function processAsync (source) { + const { code, map } = minifyHTMLLiterals(source) + return { code, map } +} + +export default { + processAsync +} diff --git a/jest.config.cjs b/jest.config.cjs index 96959a80..1403430f 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -3,7 +3,9 @@ module.exports = { testMatch: [ '/test/spec/**/*.{spec,test}.{js,jsx,ts,tsx}' ], - transform: {}, + transform: { + '^.*PickerTemplate.js$': './config/minifyHtmlInJest.js' + }, moduleFileExtensions: ['js', 'svelte'], extensionsToTreatAsEsm: ['.svelte'], testPathIgnorePatterns: ['node_modules'], @@ -24,12 +26,6 @@ module.exports = { branches: 100, functions: 100, lines: 100 - }, - './src/picker/components/Picker/Picker.svelte': { - statements: 90, - branches: 85, - functions: 90, - lines: 90 } } } From 5f5e6da867df42a0bc425362f51a4d8e8248a969 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Wed, 22 Nov 2023 22:30:03 -0800 Subject: [PATCH 045/113] fix: comment --- src/picker/components/Picker/PickerTemplate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/picker/components/Picker/PickerTemplate.js b/src/picker/components/Picker/PickerTemplate.js index 9872cb66..93db468f 100644 --- a/src/picker/components/Picker/PickerTemplate.js +++ b/src/picker/components/Picker/PickerTemplate.js @@ -197,7 +197,7 @@ export function createRootDom (state, helpers, events) { ${ map(state.currentEmojisWithCategories, (emojiWithCategory, i) => { return html` - +
` - }, skinTone => skinTone) + }, skinTone => skinTone, 'skintones') }
@@ -177,7 +171,7 @@ export function createRootDom (state, helpers, events) { ` - }, group => group.id) + }, group => group.id, 'nav') }
@@ -235,12 +229,12 @@ export function createRootDom (state, helpers, events) { aria-labelledby="menu-label-${i}" id=${state.searchMode ? 'search-results' : ''}> ${ - emojiList(emojiWithCategory.emojis, state.searchMode, /* prefix */ 'emo') + emojiList(emojiWithCategory.emojis, state.searchMode, /* prefix */ 'emo', /* uniqueId */ `emo-${emojiWithCategory.category}`) }
` - }, emojiWithCategory => emojiWithCategory.category) + }, emojiWithCategory => emojiWithCategory.category, 'emojisWithCategories') } @@ -251,7 +245,7 @@ export function createRootDom (state, helpers, events) { style="padding-inline-end: ${state.scrollbarWidth}px" data-on-click="onEmojiClick"> ${ - emojiList(state.currentFavorites, /* searchMode */ false, /* prefix */ 'fav') + emojiList(state.currentFavorites, /* searchMode */ false, /* prefix */ 'fav', /* uniqueId */ 'fav') } diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index f48a957f..68ea83a2 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -75,9 +75,9 @@ function patchChildren (newChildren, binding) { if (iteratorEndNode) { // already rendered once let currentSibling = targetNode.nextSibling let i = -1 - let hasOldChildren + let oldChildrenCount = 0 while (!(currentSibling.nodeType === Node.COMMENT_NODE && currentSibling.textContent === 'end')) { - hasOldChildren = true + oldChildrenCount++ const oldSibling = currentSibling currentSibling = currentSibling.nextSibling const newChild = newChildren[++i] @@ -87,7 +87,7 @@ function patchChildren (newChildren, binding) { parentNode.removeChild(oldSibling) } } - if (!hasOldChildren && newChildren.length) { // old array was empty, new array is not + if (oldChildrenCount !== newChildren.length) { // new children length is different from old, force re-render needsRerender = true } } else { // first render of list From d6604b2855e43a0a45a979470e1c8980d7e5f1e0 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Thu, 23 Nov 2023 09:28:33 -0800 Subject: [PATCH 048/113] fix: nav button and resize observer --- config/minifyHtmlInJest.js | 12 +++++------- rollup.config.js | 6 ++---- src/picker/components/Picker/Picker.js | 5 +++-- src/picker/components/Picker/PickerTemplate.js | 3 ++- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/config/minifyHtmlInJest.js b/config/minifyHtmlInJest.js index f1ce79d3..c5f1e559 100644 --- a/config/minifyHtmlInJest.js +++ b/config/minifyHtmlInJest.js @@ -1,10 +1,8 @@ -import { minifyHTMLLiterals } from 'minify-html-literals' - -function processAsync (source) { - const { code, map } = minifyHTMLLiterals(source) - return { code, map } -} +import { minifyHtml } from './minifyHtml.js' export default { - processAsync + processAsync (source) { + const { code, map } = minifyHtml(source) + return { code, map } + } } diff --git a/rollup.config.js b/rollup.config.js index 20ca847c..ed44a675 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -3,8 +3,8 @@ import resolve from '@rollup/plugin-node-resolve' import replace from '@rollup/plugin-replace' import strip from '@rollup/plugin-strip' import analyze from 'rollup-plugin-analyzer' -import { minifyHTMLLiterals } from 'minify-html-literals' import { buildStyles } from './bin/buildStyles.js' +import { minifyHtml } from './config/minifyHtml.js' const { NODE_ENV, DEBUG } = process.env const dev = NODE_ENV !== 'production' @@ -30,9 +30,7 @@ const baseConfig = { name: 'minify-html-in-tag-template-literals', transform (content, id) { if (id.includes('PickerTemplate.js')) { - // minify the html so that the output is smaller - const { code, map } = minifyHTMLLiterals(content) - return { code, map } + return minifyHtml(content) } } }, diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index 8e189f59..8e25677a 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -514,10 +514,11 @@ export function createRoot (target, props) { function onNavClick (event) { const { target } = event - if (!target.classList.contains('nav-emoji')) { + const closestTarget = target.closest('.nav-button') + if (!closestTarget) { return } - const groupId = parseInt(target.dataset.groupId, 10) + const groupId = parseInt(closestTarget.dataset.groupId, 10) refs.searchElement.value = '' // clear search box input state.rawSearchText = '' state.searchText = '' diff --git a/src/picker/components/Picker/PickerTemplate.js b/src/picker/components/Picker/PickerTemplate.js index 3eb71c99..f52baa6b 100644 --- a/src/picker/components/Picker/PickerTemplate.js +++ b/src/picker/components/Picker/PickerTemplate.js @@ -165,8 +165,9 @@ export function createRootDom (state, helpers, events) { aria-label="${state.i18n.categories[group.name]}" aria-selected="${!state.searchMode && state.currentGroup.id === group.id}" title="${state.i18n.categories[group.name]}" + data-group-id=${group.id} > - @@ -131,7 +133,7 @@ export function render (state, helpers, events, container, firstRender) { ` - }, group => group.id, 'nav') + }, group => group.id) }
@@ -189,12 +191,12 @@ export function render (state, helpers, events, container, firstRender) { aria-labelledby="menu-label-${i}" id=${state.searchMode ? 'search-results' : ''}> ${ - emojiList(emojiWithCategory.emojis, state.searchMode, /* prefix */ 'emo', /* uniqueId */ `emo-${emojiWithCategory.category}`) + emojiList(emojiWithCategory.emojis, state.searchMode, /* prefix */ 'emo') }
` - }, emojiWithCategory => emojiWithCategory.category, 'emojisWithCategories') + }, emojiWithCategory => emojiWithCategory.category) } @@ -205,7 +207,7 @@ export function render (state, helpers, events, container, firstRender) { style="padding-inline-end: ${`${state.scrollbarWidth}px`}" data-on-click="onEmojiClick"> ${ - emojiList(state.currentFavorites, /* searchMode */ false, /* prefix */ 'fav', /* uniqueId */ 'fav') + emojiList(state.currentFavorites, /* searchMode */ false, /* prefix */ 'fav') } diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index b38b7da7..6d1fda27 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -1,7 +1,7 @@ import { getFromMap, parseTemplate, toString } from './utils.js' const parseCache = new WeakMap() -const updatersCache = new WeakMap() +const domInstancesCache = new WeakMap() const unkeyedSymbol = Symbol('un-keyed') // Not supported in Safari <=13 @@ -263,29 +263,28 @@ function parseHtml (tokens) { } export function createFramework (state) { - let updaters = getFromMap(updatersCache, state, () => new Map()) - let iteratorKey = unkeyedSymbol + const domInstances = getFromMap(domInstancesCache, state, () => new Map()) + let domInstanceCacheKey = unkeyedSymbol function html (tokens, ...expressions) { - const updatersForKey = getFromMap(updaters, iteratorKey, () => new WeakMap()) - const updater = getFromMap(updatersForKey, tokens, () => parseHtml(tokens)) + // Each unique lexical usage of map() is considered unique due to the html`` tagged template call it makes, + // which has lexically unique tokens. The unkeyed symbol is just used for html`` usage outside of a map(). + const domInstancesForTokens = getFromMap(domInstances, tokens, () => new Map()) + const domInstance = getFromMap(domInstancesForTokens, domInstanceCacheKey, () => parseHtml(tokens)) - return updater(expressions) + return domInstance(expressions) // update with expressions } - function map (array, callback, keyFunction, mapKey) { - const originalCacheKey = iteratorKey - const originalUpdaters = updaters - updaters = getFromMap(updaters, mapKey, () => new Map()) - try { - return array.map((item, index) => { - iteratorKey = keyFunction(item) + function map (array, callback, keyFunction) { + return array.map((item, index) => { + const originalCacheKey = domInstanceCacheKey + domInstanceCacheKey = keyFunction(item) + try { return callback(item, index) - }) - } finally { - iteratorKey = originalCacheKey - updaters = originalUpdaters - } + } finally { + domInstanceCacheKey = originalCacheKey + } + }) } return { map, html } From e98c6a027d4a0f7dcdcf1f3b4fa4bf0777a8840d Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sun, 10 Dec 2023 16:39:57 -0800 Subject: [PATCH 083/113] refactor: simplify placeholder impl --- src/picker/components/Picker/framework.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index 6d1fda27..199aedbb 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -184,7 +184,8 @@ function parse (tokens) { expressionIndex: i }) - htmlString += (!withinTag && !withinAttribute) ? `` : '' + // add a placeholder comment that we can find later + htmlString += (!withinTag && !withinAttribute) ? `` : '' } const template = parseTemplate(htmlString) @@ -220,14 +221,13 @@ function traverseAndSetupBindings (dom, boundExpressions) { if (!binding.withinAttribute) { if (!foundComments) { - // find all comments once + // find all placeholder comments once foundComments = new Map() for (const childNode of element.childNodes) { // Note that minify-html-literals has already removed all non-framework comments // But just to be safe, only look for comments that contain pure integers - let match - if (childNode.nodeType === Node.COMMENT_NODE && (match = /^placeholder-(\d+)$/.exec(childNode.textContent))) { - foundComments.set(parseInt(match[1], 10), childNode) + if (childNode.nodeType === Node.COMMENT_NODE && /^\d+$/.test(childNode.textContent)) { + foundComments.set(parseInt(childNode.textContent, 10), childNode) } } } From 80493d30628899fa89b4acd165f846a4e2cd3027 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Mon, 11 Dec 2023 07:13:09 -0800 Subject: [PATCH 084/113] refactor: simplify implementation --- src/picker/components/Picker/framework.js | 34 ++++++++++------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index 199aedbb..b66ff0cd 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -107,12 +107,6 @@ function patch (expressions, bindings) { } } -function createUpdater (dom, boundExpressions) { - traverseAndSetupBindings(dom, boundExpressions) - const allBindings = [...boundExpressions.values()].flat() - return expressions => patch(expressions, allBindings) -} - function parse (tokens) { let htmlString = '' @@ -241,23 +235,23 @@ function traverseAndSetupBindings (dom, boundExpressions) { } while ((element = treeWalker.nextNode())) } -function parseHtml (tokens) { - const { - template, - boundExpressions - } = getFromMap(parseCache, tokens, () => parse(tokens)) +function cloneDomAndBind (template, boundExpressions) { + const dom = template.cloneNode(true).content.firstElementChild + const clonedBoundExpressions = cloneBoundExpressions(boundExpressions) + traverseAndSetupBindings(dom, clonedBoundExpressions) + const bindings = [...clonedBoundExpressions.values()].flat() + return { dom, bindings } +} - let updater - let dom +function parseHtml (tokens) { + // All templates and bound expressions are unique per tokens array + const { template, boundExpressions } = getFromMap(parseCache, tokens, () => parse(tokens)) - return (expressions) => { - if (!updater) { - dom = template.cloneNode(true).content.firstElementChild - const clonedBoundExpressions = cloneBoundExpressions(boundExpressions) - updater = createUpdater(dom, clonedBoundExpressions) - } - updater(expressions) + // When we parseHtml, we always return a fresh DOM instance ready to be updated + const { dom, bindings } = cloneDomAndBind(template, boundExpressions) + return function update (expressions) { + patch(expressions, bindings) return dom } } From 5fcb739ce548eda11b1759343e68d5d86a2f296b Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Mon, 11 Dec 2023 07:37:53 -0800 Subject: [PATCH 085/113] fix: use element not node --- src/picker/components/Picker/framework.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index b66ff0cd..ef5b2c0e 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -87,7 +87,7 @@ function patch (expressions, bindings) { let newNode if (Array.isArray(expression)) { // array of html tag templates patchChildren(expression, binding) - } else if (expression instanceof Node) { // html tag template returning a DOM node + } else if (expression instanceof Element) { // html tag template returning a DOM element newNode = expression if (newNode !== targetNode) { targetNode.replaceWith(newNode) From ff3bb13ce3762e353c5080d22144e36723d06ac1 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Mon, 11 Dec 2023 19:08:17 -0800 Subject: [PATCH 086/113] refactor: avoid polymorphism in framework and simplify --- src/picker/components/Picker/framework.js | 145 +++++++++++++--------- 1 file changed, 83 insertions(+), 62 deletions(-) diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index ef5b2c0e..2725c93c 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -40,53 +40,55 @@ function doChildrenNeedRerender (parentNode, newChildren) { return oldChildrenCount !== newChildren.length } -function patchChildren (newChildren, binding) { - const { targetNode } = binding - let { iteratorParentNode } = binding +function patchChildren (newChildren, instanceBinding) { + const { targetNode } = instanceBinding + let { targetParentNode } = instanceBinding let needsRerender = false - if (iteratorParentNode) { // already rendered once - needsRerender = doChildrenNeedRerender(iteratorParentNode, newChildren) + if (targetParentNode) { // already rendered once + needsRerender = doChildrenNeedRerender(targetParentNode, newChildren) } else { // first render of list needsRerender = true - binding.targetNode = undefined - binding.iteratorParentNode = iteratorParentNode = targetNode.parentNode + instanceBinding.targetNode = undefined // placeholder comment not needed anymore, free memory + instanceBinding.targetParentNode = targetParentNode = targetNode.parentNode } // console.log('needsRerender?', needsRerender, 'newChildren', newChildren) // avoid re-rendering list if the dom nodes are exactly the same before and after if (needsRerender) { - replaceChildren(iteratorParentNode, newChildren) + replaceChildren(targetParentNode, newChildren) } } -function patch (expressions, bindings) { - for (const binding of bindings) { +function patch (expressions, instanceBindings) { + for (const instanceBinding of instanceBindings) { const { - expressionIndex, - withinAttribute, targetNode, - element, - attributeName, - attributeValuePre, - attributeValuePost, - lastExpression - } = binding + currentExpression, + binding: { + expressionIndex, + withinAttribute, + attributeName, + attributeValuePre, + attributeValuePost + } + } = instanceBinding + const expression = expressions[expressionIndex] - if (lastExpression === expression) { + if (currentExpression === expression) { // no need to update, same as before continue } - binding.lastExpression = expression + instanceBinding.currentExpression = expression if (withinAttribute) { - element.setAttribute(attributeName, attributeValuePre + toString(expression) + attributeValuePost) - } else { // text node / dom node replacement + targetNode.setAttribute(attributeName, attributeValuePre + toString(expression) + attributeValuePost) + } else { // text node / child element / children replacement let newNode if (Array.isArray(expression)) { // array of html tag templates - patchChildren(expression, binding) + patchChildren(expression, instanceBinding) } else if (expression instanceof Element) { // html tag template returning a DOM element newNode = expression if (newNode !== targetNode) { @@ -101,7 +103,7 @@ function patch (expressions, bindings) { } } if (newNode) { - binding.targetNode = newNode + instanceBinding.targetNode = newNode } } } @@ -114,7 +116,7 @@ function parse (tokens) { let withinAttribute = false let elementIndexCounter = -1 // depth-first traversal order - const boundExpressions = new Map() + const elementsToBindings = new Map() const elementIndexes = [] for (let i = 0, len = tokens.length; i < len; i++) { @@ -154,10 +156,10 @@ function parse (tokens) { } const elementIndex = elementIndexes[elementIndexes.length - 1] - let bindings = boundExpressions.get(elementIndex) + let bindings = elementsToBindings.get(elementIndex) if (!bindings) { bindings = [] - boundExpressions.set(elementIndex, bindings) + elementsToBindings.set(elementIndex, bindings) } let attributeName @@ -169,14 +171,21 @@ function parse (tokens) { attributeValuePost = /^([^">]*)/.exec(tokens[i + 1])[1] } - bindings.push({ + const binding = { withinTag, withinAttribute, attributeName, attributeValuePre, attributeValuePost, expressionIndex: i - }) + } + + if (process.env.NODE_ENV !== 'production') { + // remind myself that this object is supposed to be immutable + Object.freeze(binding) + } + + bindings.push(binding) // add a placeholder comment that we can find later htmlString += (!withinTag && !withinAttribute) ? `` : '' @@ -186,72 +195,84 @@ function parse (tokens) { return { template, - boundExpressions + elementsToBindings } } -function cloneBoundExpressions (boundExpressions) { - const map = new Map() - for (const [id, bindings] of boundExpressions.entries()) { - map.set(id, bindings.map(binding => ({ ...binding }))) +function findBindingIdsToPlaceholderComments (element) { + const result = new Map() + for (const childNode of element.childNodes) { + // Note that minify-html-literals has already removed all non-framework comments + // But just to be safe, only look for comments that contain pure integers + if (childNode.nodeType === Node.COMMENT_NODE && /^\d+$/.test(childNode.textContent)) { + result.set(parseInt(childNode.textContent, 10), childNode) + } } - return map + return result } -function traverseAndSetupBindings (dom, boundExpressions) { +function traverseAndSetupBindings (dom, elementsToBindings) { + const instanceBindings = [] // traverse dom const treeWalker = document.createTreeWalker(dom, NodeFilter.SHOW_ELEMENT) let element = dom let elementIndex = -1 do { - const bindings = boundExpressions.get(++elementIndex) + const bindings = elementsToBindings.get(++elementIndex) if (bindings) { - let foundComments + let bindingIdsToPlaceholderComments for (let i = 0; i < bindings.length; i++) { const binding = bindings[i] - binding.element = element - - if (!binding.withinAttribute) { - if (!foundComments) { - // find all placeholder comments once - foundComments = new Map() - for (const childNode of element.childNodes) { - // Note that minify-html-literals has already removed all non-framework comments - // But just to be safe, only look for comments that contain pure integers - if (childNode.nodeType === Node.COMMENT_NODE && /^\d+$/.test(childNode.textContent)) { - foundComments.set(parseInt(childNode.textContent, 10), childNode) - } - } + let targetNode + if (binding.withinAttribute) { + targetNode = element + } else { // not within an attribute, so has a placeholder comment + if (!bindingIdsToPlaceholderComments) { // find all placeholder comments once + bindingIdsToPlaceholderComments = findBindingIdsToPlaceholderComments(element) } - binding.targetNode = foundComments.get(i) - if (process.env.NODE_ENV !== 'production' && !binding.targetNode) { - throw new Error('should not be undefined') + targetNode = bindingIdsToPlaceholderComments.get(i) + if (process.env.NODE_ENV !== 'production' && !targetNode) { + throw new Error('targetNode should not be undefined') } } + + const instanceBinding = { + binding, + targetNode, + targetParentNode: undefined, + currentExpression: undefined + } + + if (process.env.NODE_ENV !== 'production') { + // remind myself that this object is supposed to be monomorphic (for better JS engine perf) + Object.seal(instanceBinding) + } + + instanceBindings.push(instanceBinding) } } } while ((element = treeWalker.nextNode())) + + return instanceBindings } -function cloneDomAndBind (template, boundExpressions) { +function cloneDomAndBind (template, elementsToBindings) { const dom = template.cloneNode(true).content.firstElementChild - const clonedBoundExpressions = cloneBoundExpressions(boundExpressions) - traverseAndSetupBindings(dom, clonedBoundExpressions) - const bindings = [...clonedBoundExpressions.values()].flat() - return { dom, bindings } + const instanceBindings = traverseAndSetupBindings(dom, elementsToBindings) + return { dom, instanceBindings } } function parseHtml (tokens) { // All templates and bound expressions are unique per tokens array - const { template, boundExpressions } = getFromMap(parseCache, tokens, () => parse(tokens)) + const { template, elementsToBindings } = getFromMap(parseCache, tokens, () => parse(tokens)) // When we parseHtml, we always return a fresh DOM instance ready to be updated - const { dom, bindings } = cloneDomAndBind(template, boundExpressions) + const { dom, instanceBindings } = cloneDomAndBind(template, elementsToBindings) return function update (expressions) { - patch(expressions, bindings) + patch(expressions, instanceBindings) return dom } } From baa696b07b6a5ca63fc2a7d0b6d111facec915b1 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Mon, 11 Dec 2023 19:15:12 -0800 Subject: [PATCH 087/113] refactor: simplify further --- src/picker/components/Picker/framework.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index 2725c93c..8581ef86 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -67,7 +67,6 @@ function patch (expressions, instanceBindings) { currentExpression, binding: { expressionIndex, - withinAttribute, attributeName, attributeValuePre, attributeValuePost @@ -83,7 +82,7 @@ function patch (expressions, instanceBindings) { instanceBinding.currentExpression = expression - if (withinAttribute) { + if (attributeName) { // attribute replacement targetNode.setAttribute(attributeName, attributeValuePre + toString(expression) + attributeValuePost) } else { // text node / child element / children replacement let newNode @@ -172,8 +171,6 @@ function parse (tokens) { } const binding = { - withinTag, - withinAttribute, attributeName, attributeValuePre, attributeValuePost, @@ -226,9 +223,9 @@ function traverseAndSetupBindings (dom, elementsToBindings) { const binding = bindings[i] let targetNode - if (binding.withinAttribute) { + if (binding.attributeName) { // attribute binding targetNode = element - } else { // not within an attribute, so has a placeholder comment + } else { // not an attribute binding, so has a placeholder comment if (!bindingIdsToPlaceholderComments) { // find all placeholder comments once bindingIdsToPlaceholderComments = findBindingIdsToPlaceholderComments(element) } From 9df6a5802d803cacff8d59369ed2765e0dbb948e Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Mon, 11 Dec 2023 19:17:16 -0800 Subject: [PATCH 088/113] refactor: rename variable --- src/picker/components/Picker/framework.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index 8581ef86..08d3a718 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -196,7 +196,7 @@ function parse (tokens) { } } -function findBindingIdsToPlaceholderComments (element) { +function findPlaceholderComments (element) { const result = new Map() for (const childNode of element.childNodes) { // Note that minify-html-literals has already removed all non-framework comments @@ -218,7 +218,7 @@ function traverseAndSetupBindings (dom, elementsToBindings) { do { const bindings = elementsToBindings.get(++elementIndex) if (bindings) { - let bindingIdsToPlaceholderComments + let placeholderComments for (let i = 0; i < bindings.length; i++) { const binding = bindings[i] @@ -226,10 +226,10 @@ function traverseAndSetupBindings (dom, elementsToBindings) { if (binding.attributeName) { // attribute binding targetNode = element } else { // not an attribute binding, so has a placeholder comment - if (!bindingIdsToPlaceholderComments) { // find all placeholder comments once - bindingIdsToPlaceholderComments = findBindingIdsToPlaceholderComments(element) + if (!placeholderComments) { // find all placeholder comments once + placeholderComments = findPlaceholderComments(element) } - targetNode = bindingIdsToPlaceholderComments.get(i) + targetNode = placeholderComments.get(i) if (process.env.NODE_ENV !== 'production' && !targetNode) { throw new Error('targetNode should not be undefined') } From 962ad95b81088ae644dd2e933abcedf0147af608 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Mon, 11 Dec 2023 19:19:02 -0800 Subject: [PATCH 089/113] refactor: move dev-only check --- src/picker/components/Picker/framework.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index 08d3a718..de69dc5e 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -230,9 +230,10 @@ function traverseAndSetupBindings (dom, elementsToBindings) { placeholderComments = findPlaceholderComments(element) } targetNode = placeholderComments.get(i) - if (process.env.NODE_ENV !== 'production' && !targetNode) { - throw new Error('targetNode should not be undefined') - } + } + + if (process.env.NODE_ENV !== 'production' && !targetNode) { + throw new Error('targetNode should not be undefined') } const instanceBinding = { From b9f7bfe2651866a74eecf72f47d066ac275cfc1c Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Mon, 11 Dec 2023 21:47:59 -0800 Subject: [PATCH 090/113] refactor: simplify placeholder lookup logic --- src/picker/components/Picker/framework.js | 30 ++++++++++------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index de69dc5e..851ee888 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -196,16 +196,19 @@ function parse (tokens) { } } -function findPlaceholderComments (element) { - const result = new Map() - for (const childNode of element.childNodes) { +function findPlaceholderComment (element, bindingId) { + // If we had a lot of placeholder comments to find, it would make more sense to build up a map once + // rather than search the DOM every time. But it turns out that we always only have one child, + // and it's the comment node, so searching every time is actually faster. + let childNode = element.firstChild + while (childNode) { // Note that minify-html-literals has already removed all non-framework comments - // But just to be safe, only look for comments that contain pure integers - if (childNode.nodeType === Node.COMMENT_NODE && /^\d+$/.test(childNode.textContent)) { - result.set(parseInt(childNode.textContent, 10), childNode) + // So we just need to look for comments that have exactly the bindingId as its text content + if (childNode.nodeType === Node.COMMENT_NODE && childNode.textContent === toString(bindingId)) { + return childNode } + childNode = childNode.nextSibling } - return result } function traverseAndSetupBindings (dom, elementsToBindings) { @@ -218,19 +221,12 @@ function traverseAndSetupBindings (dom, elementsToBindings) { do { const bindings = elementsToBindings.get(++elementIndex) if (bindings) { - let placeholderComments for (let i = 0; i < bindings.length; i++) { const binding = bindings[i] - let targetNode - if (binding.attributeName) { // attribute binding - targetNode = element - } else { // not an attribute binding, so has a placeholder comment - if (!placeholderComments) { // find all placeholder comments once - placeholderComments = findPlaceholderComments(element) - } - targetNode = placeholderComments.get(i) - } + const targetNode = binding.attributeName + ? element // attribute binding, just use the element itself + : findPlaceholderComment(element, i) // not an attribute binding, so has a placeholder comment if (process.env.NODE_ENV !== 'production' && !targetNode) { throw new Error('targetNode should not be undefined') From 8ca786ac872f4927d94a29dbef7b6b90f7cba9db Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Tue, 12 Dec 2023 07:59:05 -0800 Subject: [PATCH 091/113] refactor: clean up logic a bit --- src/picker/components/Picker/Picker.js | 32 ++++--------- .../components/Picker/PickerTemplate.js | 47 +++++++++++-------- src/picker/components/Picker/reactivity.js | 17 +++++-- 3 files changed, 47 insertions(+), 49 deletions(-) diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index 1243949b..12545aa1 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -30,8 +30,9 @@ const EMPTY_ARRAY = [] const { assign } = Object export function createRoot (target, props) { - const { state, createEffect, destroyState } = createState() - const destroyCallbacks = [] + const refs = {} + const abortController = new AbortController() + const { state, createEffect } = createState(abortController.signal) // initial state assign(state, { @@ -176,22 +177,12 @@ export function createRoot (target, props) { onSkinToneOptionsKeyup, onSearchInput } + const actions = { + calculateEmojiGridStyle + } - let unmount - let refs - let firstRender = true createEffect(() => { - const rendered = render(state, helpers, events, target, firstRender) - if (firstRender) { - firstRender = false - - unmount = rendered.unmount - refs = rendered.refs - - // on first render, set up the ResizeObserver - const calculator = calculateEmojiGridStyle(refs.emojiGrid) - destroyCallbacks.push(calculator.destroy) - } + render(target, state, helpers, events, actions, refs, abortController.signal) }) // @@ -742,14 +733,7 @@ export function createRoot (target, props) { assign(state, newState) }, $destroy () { - destroyState() - unmount() - if (process.env.NODE_ENV !== 'production') { - delete window.state - } - for (const destroyCallback of destroyCallbacks) { - destroyCallback() - } + abortController.abort() console.log('destroyed!') } } diff --git a/src/picker/components/Picker/PickerTemplate.js b/src/picker/components/Picker/PickerTemplate.js index 40e16b60..1be7d315 100644 --- a/src/picker/components/Picker/PickerTemplate.js +++ b/src/picker/components/Picker/PickerTemplate.js @@ -1,6 +1,6 @@ import { createFramework } from './framework.js' -export function render (state, helpers, events, container, firstRender) { +export function render (container, state, helpers, events, actions, refs, abortSignal) { const { labelWithSkin, titleForEmoji, unicodeWithSkin } = helpers const { html, map } = createFramework(state) @@ -158,7 +158,7 @@ export function render (state, helpers, events, container, firstRender) { tabIndex="0" data-on-click="onEmojiClick" > -
+
${ map(state.currentEmojisWithCategories, (emojiWithCategory, i) => { return html` @@ -220,35 +220,42 @@ export function render (state, helpers, events, container, firstRender) { const rootDom = section() - let refs - - if (firstRender) { // not a re-render + if (rootDom.parentNode !== container) { // not a re-render container.appendChild(rootDom) - // we only bind events/refs once - there is no need to find them again given this component structure + // we only bind events/refs/actions once - there is no need to find them again given this component structure + + // helper for traversing the dom, finding elements by an attribute, and getting the attribute value + const forElementWithAttribute = (attributeName, callback) => { + for (const element of container.querySelectorAll(`[${attributeName}]`)) { + callback(element, element.getAttribute(attributeName)) + } + } // bind events for (const eventName of ['click', 'focusout', 'input', 'keydown', 'keyup']) { - for (const element of rootDom.querySelectorAll(`[data-on-${eventName}]`)) { - const listenerName = element.getAttribute(`data-on-${eventName}`) + forElementWithAttribute(`data-on-${eventName}`, (element, listenerName) => { element.addEventListener(eventName, events[listenerName]) - } + }) } // find refs - refs = {} - for (const element of container.querySelectorAll('[data-ref]')) { - const { ref } = element.dataset + forElementWithAttribute('data-ref', (element, ref) => { refs[ref] = element - } - } + }) - function unmount () { - container.removeChild(rootDom) - } + // set up actions and destroy/abort logic + const destroyCallbacks = [] + forElementWithAttribute('data-action', (element, action) => { + const { destroy } = actions[action](element) + destroyCallbacks.push(destroy) + }) - return { - refs, - unmount + abortSignal.addEventListener('abort', () => { + for (const destroyCallback of destroyCallbacks) { + destroyCallback() + } + container.removeChild(rootDom) + }) } } diff --git a/src/picker/components/Picker/reactivity.js b/src/picker/components/Picker/reactivity.js index 54b57355..59e6f3bf 100644 --- a/src/picker/components/Picker/reactivity.js +++ b/src/picker/components/Picker/reactivity.js @@ -1,4 +1,4 @@ -export function createState () { +export function createState (abortSignal) { let destroyed = false let currentObserver @@ -77,15 +77,22 @@ export function createState () { return runnable() } + // for debugging if (process.env.NODE_ENV !== 'production') { window.state = state } + // destroy logic + abortSignal.addEventListener('abort', () => { + destroyed = true + + if (process.env.NODE_ENV !== 'production') { + delete window.state + } + }) + return { state, - createEffect, - destroyState () { - destroyed = true - } + createEffect } } From 12615eb0db8151acd995e0bb9e83521e8fdbe372 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Tue, 12 Dec 2023 08:15:06 -0800 Subject: [PATCH 092/113] refactor: refactor destroy logic further --- src/picker/components/Picker/Picker.js | 11 ++++++----- src/picker/components/Picker/PickerTemplate.js | 10 +++------- src/picker/utils/widthCalculator.js | 13 ++++++------- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index 12545aa1..30ac9020 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -14,7 +14,7 @@ import { } from '../../constants' import { uniqBy } from '../../../shared/uniqBy' import { summarizeEmojisForUI } from '../../utils/summarizeEmojisForUI' -import * as widthCalculator from '../../utils/widthCalculator' +import { calculateWidth } from '../../utils/widthCalculator' import { checkZwjSupport } from '../../utils/checkZwjSupport' import { requestPostAnimationFrame } from '../../utils/requestPostAnimationFrame' import { requestAnimationFrame } from '../../utils/requestAnimationFrame' @@ -32,7 +32,8 @@ const { assign } = Object export function createRoot (target, props) { const refs = {} const abortController = new AbortController() - const { state, createEffect } = createState(abortController.signal) + const abortSignal = abortController.signal + const { state, createEffect } = createState(abortSignal) // initial state assign(state, { @@ -182,7 +183,7 @@ export function createRoot (target, props) { } createEffect(() => { - render(target, state, helpers, events, actions, refs, abortController.signal) + render(target, state, helpers, events, actions, refs, abortSignal) }) // @@ -355,7 +356,7 @@ export function createRoot (target, props) { // function calculateEmojiGridStyle (node) { - return widthCalculator.calculateWidth(node, width => { + calculateWidth(node, abortSignal, width => { /* istanbul ignore next */ if (process.env.NODE_ENV !== 'test') { // jsdom throws errors for this kind of fancy stuff // read all the style/layout calculations we need to make @@ -734,7 +735,7 @@ export function createRoot (target, props) { }, $destroy () { abortController.abort() - console.log('destroyed!') + console.log('Component destroyed') } } } diff --git a/src/picker/components/Picker/PickerTemplate.js b/src/picker/components/Picker/PickerTemplate.js index 1be7d315..9815e6ad 100644 --- a/src/picker/components/Picker/PickerTemplate.js +++ b/src/picker/components/Picker/PickerTemplate.js @@ -244,17 +244,13 @@ export function render (container, state, helpers, events, actions, refs, abortS refs[ref] = element }) - // set up actions and destroy/abort logic - const destroyCallbacks = [] + // set up actions forElementWithAttribute('data-action', (element, action) => { - const { destroy } = actions[action](element) - destroyCallbacks.push(destroy) + actions[action](element) }) + // destroy/abort logic abortSignal.addEventListener('abort', () => { - for (const destroyCallback of destroyCallbacks) { - destroyCallback() - } container.removeChild(rootDom) }) } diff --git a/src/picker/utils/widthCalculator.js b/src/picker/utils/widthCalculator.js index 2096e48b..c96cfe32 100644 --- a/src/picker/utils/widthCalculator.js +++ b/src/picker/utils/widthCalculator.js @@ -11,7 +11,7 @@ export const resetResizeObserverSupported = () => { resizeObserverSupported = typeof ResizeObserver === 'function' } -export function calculateWidth (node, onUpdate) { +export function calculateWidth (node, abortSignal, onUpdate) { let resizeObserver if (resizeObserverSupported) { resizeObserver = new ResizeObserver(entries => ( @@ -25,11 +25,10 @@ export function calculateWidth (node, onUpdate) { } // cleanup function (called on destroy) - return { - destroy () { - if (resizeObserver) { - resizeObserver.disconnect() - } + abortSignal.addEventListener('abort', () => { + if (resizeObserver) { + console.log('ResizeObserver destroyed') + resizeObserver.disconnect() } - } + }) } From 71511d99f49a8b87d1d963d996caef8b98e9e0a6 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sat, 16 Dec 2023 10:27:36 -0800 Subject: [PATCH 093/113] fix: improve coverage on the picker --- src/picker/components/Picker/Picker.js | 17 ++++++----------- src/picker/components/Picker/framework.js | 1 + test/spec/picker/Picker.test.js | 23 +++++++++++++++++++++++ 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index 30ac9020..b6cc7f3b 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -392,7 +392,7 @@ export function createRoot (target, props) { updateCurrentEmojis(newEmojis) updateSearchMode(true) } - } else if (currentGroup) { + } else { const { id: currentGroupId } = currentGroup // avoid race condition where currentGroupId is -1 and customEmoji is undefined/empty if (currentGroupId !== -1 || (customEmoji && customEmoji.length)) { @@ -544,11 +544,12 @@ export function createRoot (target, props) { case 'ArrowUp': return goToNextOrPrevious(true) case 'Enter': - if (state.activeSearchItem !== -1) { + if (state.activeSearchItem === -1) { + // focus the first option in the list since the list must be non-empty at this point (it's verified above) + state.activeSearchItem = 0 + } else { // there is already an active search item halt(event) return clickEmoji(state.currentEmojis[state.activeSearchItem].id) - } else if (state.currentEmojis.length) { - state.activeSearchItem = 0 } } } @@ -652,6 +653,7 @@ export function createRoot (target, props) { function onClickSkinToneButton (event) { state.skinTonePickerExpanded = !state.skinTonePickerExpanded state.activeSkinTone = state.currentSkinTone + // this should always be true, since the button is obscured by the listbox, so this `if` is just to be sure if (state.skinTonePickerExpanded) { halt(event) requestAnimationFrame(() => focus('skintone-list')) @@ -672,10 +674,6 @@ export function createRoot (target, props) { }) function onSkinToneOptionsKeydown (event) { - if (!state.skinTonePickerExpanded) { - return - } - const changeActiveSkinTone = async nextSkinTone => { halt(event) state.activeSkinTone = nextSkinTone @@ -703,9 +701,6 @@ export function createRoot (target, props) { } function onSkinToneOptionsKeyup (event) { - if (!state.skinTonePickerExpanded) { - return - } switch (event.key) { case ' ': // enter on keydown, space on keyup. this is just how browsers work for buttons diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index 851ee888..b60d66a9 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -33,6 +33,7 @@ function doChildrenNeedRerender (parentNode, newChildren) { oldChild = oldChild.nextSibling oldChildrenCount++ } + /* istanbul ignore if */ if (process.env.NODE_ENV !== 'production' && oldChildrenCount !== parentNode.children.length) { throw new Error('parentNode.children.length is different from oldChildrenCount, it should not be') } diff --git a/test/spec/picker/Picker.test.js b/test/spec/picker/Picker.test.js index dcdce425..e971ca6b 100644 --- a/test/spec/picker/Picker.test.js +++ b/test/spec/picker/Picker.test.js @@ -178,6 +178,16 @@ describe('Picker tests', () => { await waitFor(() => expect(queryAllByRole('listbox', { name: 'Skin tones' })).toHaveLength(0)) }) + test('Click skintone button while picker is open', async () => { + // this should not be possible since the picker covers the button when it's open, + // but this is for test coverage, and just to be safe + await openSkintoneListbox(container) + await fireEvent.click(getByRole('button', { name: /Choose a skin tone/ })) + + // listbox closes + await waitFor(() => expect(queryAllByRole('listbox', { name: 'Skin tones' })).toHaveLength(0)) + }) + test('nav keyboard test', async () => { getByRole('tab', { name: 'Smileys and emoticons', selected: true }).focus() @@ -339,6 +349,19 @@ describe('Picker tests', () => { ), { timeout: 5000 }) }, 10000) + test('press enter on an empty search list', async () => { + await tick(120) + type(getByRole('combobox'), 'xxxyyyzzzhahaha') + await waitFor(() => expect(queryAllByRole('option')).toHaveLength(0)) + expect(getByRole('combobox').getAttribute('aria-activedescendant')).toBeFalsy() + await tick(120) + fireEvent.keyDown(getByRole('combobox'), { key: 'Enter', code: 'Enter' }) + await tick(120) + // should do nothing basically since there's nothing to search for + expect(queryAllByRole('option')).toHaveLength(0) + expect(getByRole('combobox').getAttribute('aria-activedescendant')).toBeFalsy() + }, 10000) + test('press enter to make first search item active - custom emoji', async () => { picker.customEmoji = [ { From b0e9181fc0c67104451a5b5d3334c2b801862ba0 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sat, 16 Dec 2023 10:50:13 -0800 Subject: [PATCH 094/113] fix: improve coverage on framework itself --- src/picker/components/Picker/framework.js | 10 +++- test/spec/picker/framework.test.js | 60 +++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 test/spec/picker/framework.test.js diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index b60d66a9..db9b08b9 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -91,9 +91,13 @@ function patch (expressions, instanceBindings) { patchChildren(expression, instanceBinding) } else if (expression instanceof Element) { // html tag template returning a DOM element newNode = expression - if (newNode !== targetNode) { - targetNode.replaceWith(newNode) + /* istanbul ignore if */ + if (process.env.NODE_ENV !== 'production' && newNode === targetNode) { + // it seems impossible for the framework to get into this state, may as well assert on it + // worst case scenario is we lose focus if we call replaceWith on the same node + throw new Error('the newNode and targetNode are the same, this should never happen') } + targetNode.replaceWith(newNode) } else { // primitive - string, number, etc if (targetNode.nodeType === Node.TEXT_NODE) { // already transformed into a text node targetNode.nodeValue = toString(expression) @@ -178,6 +182,7 @@ function parse (tokens) { expressionIndex: i } + /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production') { // remind myself that this object is supposed to be immutable Object.freeze(binding) @@ -240,6 +245,7 @@ function traverseAndSetupBindings (dom, elementsToBindings) { currentExpression: undefined } + /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production') { // remind myself that this object is supposed to be monomorphic (for better JS engine perf) Object.seal(instanceBinding) diff --git a/test/spec/picker/framework.test.js b/test/spec/picker/framework.test.js new file mode 100644 index 00000000..3cbd8a0a --- /dev/null +++ b/test/spec/picker/framework.test.js @@ -0,0 +1,60 @@ +import { createFramework } from '../../../src/picker/components/Picker/framework.js' + +describe('framework', () => { + test('patches a node', () => { + const state = { name: 'foo' } + + const { html } = createFramework(state) + + let node + const render = () => { + node = html`
${html`${state.name}`}
` + } + + render() + expect(node.outerHTML).toBe('
foo
') + + state.name = 'bar' + render() + expect(node.outerHTML).toBe('
bar
') + }) + + test('replaces one node with a totally different one', () => { + const state = { name: 'foo' } + + const { html } = createFramework(state) + + let node + const render = () => { + node = html`
${ + state.name === 'foo' ? html`${state.name}` : html`` + }
` + } + + render() + expect(node.outerHTML).toBe('
foo
') + + state.name = 'bar' + render() + expect(node.outerHTML).toBe('
') + }) + + test('return the same exact node after a re-render', () => { + const state = { name: 'foo' } + + const { html } = createFramework(state) + + let node + let cached + const render = () => { + cached = cached ?? html`${state.name}` + node = html`
${cached}
` + } + + render() + expect(node.outerHTML).toBe('
foo
') + + render() + expect(node.outerHTML).toBe('
foo
') + }) +}) From ef5e8166b589752fde312f3016ace27320069f35 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sat, 16 Dec 2023 11:16:55 -0800 Subject: [PATCH 095/113] fix: more code coverage improvements --- src/picker/components/Picker/framework.js | 23 +++++++++---- src/picker/components/Picker/reactivity.js | 3 ++ test/spec/picker/framework.test.js | 38 ++++++++++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/picker/components/Picker/framework.js b/src/picker/components/Picker/framework.js index db9b08b9..9710cc39 100644 --- a/src/picker/components/Picker/framework.js +++ b/src/picker/components/Picker/framework.js @@ -136,12 +136,18 @@ function parse (tokens) { switch (char) { case '<': { const nextChar = token.charAt(j + 1) - if (nextChar !== '!' && nextChar !== '/') { // not a closing tag or comment - withinTag = true - elementIndexes.push(++elementIndexCounter) - } else if (nextChar === '/') { + /* istanbul ignore if */ + if (process.env.NODE_ENV !== 'production' && !/[/a-z]/.test(nextChar)) { + // we don't need to support comments (' 2 < 3
' + throw new Error('framework currently only supports a < followed by / or a-z') + } + if (nextChar === '/') { // closing tag // leaving an element elementIndexes.pop() + } else { // not a closing tag + withinTag = true + elementIndexes.push(++elementIndexCounter) } break } @@ -151,9 +157,13 @@ function parse (tokens) { break } case '=': { - if (withinTag) { - withinAttribute = true + /* istanbul ignore if */ + if (process.env.NODE_ENV !== 'production' && !withinTag) { + // we don't currently support '=' anywhere but inside a tag, e.g. + // we don't support '
2 + 2 = 4
' + throw new Error('framework currently does not support = anywhere but inside a tag') } + withinAttribute = true break } } @@ -234,6 +244,7 @@ function traverseAndSetupBindings (dom, elementsToBindings) { ? element // attribute binding, just use the element itself : findPlaceholderComment(element, i) // not an attribute binding, so has a placeholder comment + /* istanbul ignore if */ if (process.env.NODE_ENV !== 'production' && !targetNode) { throw new Error('targetNode should not be undefined') } diff --git a/src/picker/components/Picker/reactivity.js b/src/picker/components/Picker/reactivity.js index 59e6f3bf..e19abe4e 100644 --- a/src/picker/components/Picker/reactivity.js +++ b/src/picker/components/Picker/reactivity.js @@ -14,6 +14,7 @@ export function createState (abortSignal) { if (destroyed) { return } + /* istanbul ignore if */ if (process.env.NODE_ENV !== 'production' && recursionDepth === MAX_RECURSION_DEPTH) { throw new Error('max recursion depth, you probably didn\'t mean to do this') } @@ -78,6 +79,7 @@ export function createState (abortSignal) { } // for debugging + /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production') { window.state = state } @@ -86,6 +88,7 @@ export function createState (abortSignal) { abortSignal.addEventListener('abort', () => { destroyed = true + /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production') { delete window.state } diff --git a/test/spec/picker/framework.test.js b/test/spec/picker/framework.test.js index 3cbd8a0a..a4b26401 100644 --- a/test/spec/picker/framework.test.js +++ b/test/spec/picker/framework.test.js @@ -57,4 +57,42 @@ describe('framework', () => { render() expect(node.outerHTML).toBe('
foo
') }) + + test('render two dynamic expressions inside the same element', () => { + const state = { name1: 'foo', name2: 'bar' } + + const { html } = createFramework(state) + + let node + const render = () => { + node = html`
${state.name1}${state.name2}
` + } + + render() + expect(node.outerHTML).toBe('
foobar
') + + state.name1 = 'baz' + state.name2 = 'quux' + render() + expect(node.outerHTML).toBe('
bazquux
') + }) + + test('render a mix of dynamic and static text nodes in the same element', () => { + const state = { name1: 'foo', name2: 'bar' } + + const { html } = createFramework(state) + + let node + const render = () => { + node = html`
1${state.name1}2${state.name2}3
` + } + + render() + expect(node.outerHTML).toBe('
1foo2bar3
') + + state.name1 = 'baz' + state.name2 = 'quux' + render() + expect(node.outerHTML).toBe('
1baz2quux3
') + }) }) From b4c1dfb309c3222538185efbd0bcd7a943a21422 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sat, 16 Dec 2023 11:42:53 -0800 Subject: [PATCH 096/113] fix: 100% code coverage --- config/minifyHtmlInJest.js | 6 ++++-- jest.config.cjs | 14 ++++---------- rollup.config.js | 4 +++- src/picker/components/Picker/PickerTemplate.js | 3 ++- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/config/minifyHtmlInJest.js b/config/minifyHtmlInJest.js index 783f187f..22ce8849 100644 --- a/config/minifyHtmlInJest.js +++ b/config/minifyHtmlInJest.js @@ -1,7 +1,9 @@ import { minifyHTMLLiterals } from 'minify-html-literals' export default { - processAsync (source) { - return minifyHTMLLiterals(source) + processAsync (source, fileName) { + return minifyHTMLLiterals(source, { + fileName + }) } } diff --git a/jest.config.cjs b/jest.config.cjs index 2136dcf1..54c44fc7 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -21,16 +21,10 @@ module.exports = { ], coverageThreshold: { global: { - statements: 95, - branches: 95, - functions: 95, - lines: 95 - }, - './src/picker/components/Picker/**/*': { - statements: 90, - branches: 85, - functions: 90, - lines: 90 + statements: 100, + branches: 100, + functions: 100, + lines: 100 } } } diff --git a/rollup.config.js b/rollup.config.js index 52aab59c..fd10275c 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -30,7 +30,9 @@ const baseConfig = { name: 'minify-html-in-tag-template-literals', transform (content, id) { if (id.includes('PickerTemplate.js')) { - return minifyHTMLLiterals(content) + return minifyHTMLLiterals(content, { + fileName: id + }) } } }, diff --git a/src/picker/components/Picker/PickerTemplate.js b/src/picker/components/Picker/PickerTemplate.js index 9815e6ad..f09401be 100644 --- a/src/picker/components/Picker/PickerTemplate.js +++ b/src/picker/components/Picker/PickerTemplate.js @@ -137,8 +137,9 @@ export function render (container, state, helpers, events, actions, refs, abortS }
+
+ style="transform: translateX(${(/* istanbul ignore next */ (state.isRtl ? -1 : 1)) * state.currentGroupIndex * 100}%)">
From 18a766e14579fad54e67aa01a660409ef67d3039 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sat, 16 Dec 2023 11:45:02 -0800 Subject: [PATCH 097/113] fix: add more safety checks --- src/picker/components/Picker/Picker.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/picker/components/Picker/Picker.js b/src/picker/components/Picker/Picker.js index b6cc7f3b..d3e48f2d 100644 --- a/src/picker/components/Picker/Picker.js +++ b/src/picker/components/Picker/Picker.js @@ -674,6 +674,11 @@ export function createRoot (target, props) { }) function onSkinToneOptionsKeydown (event) { + // this should never happen, but makes me nervous not to have it + /* istanbul ignore if */ + if (!state.skinTonePickerExpanded) { + return + } const changeActiveSkinTone = async nextSkinTone => { halt(event) state.activeSkinTone = nextSkinTone @@ -701,6 +706,11 @@ export function createRoot (target, props) { } function onSkinToneOptionsKeyup (event) { + // this should never happen, but makes me nervous not to have it + /* istanbul ignore if */ + if (!state.skinTonePickerExpanded) { + return + } switch (event.key) { case ' ': // enter on keydown, space on keyup. this is just how browsers work for buttons From ba02269e5a10a30da8a23e391b2cb1615ce4cf45 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sat, 16 Dec 2023 11:55:35 -0800 Subject: [PATCH 098/113] docs: add notes on limitations of the framework --- CONTRIBUTING.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 190ab162..066fa8ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,6 +64,28 @@ Some explanations of why the code is structured the way it is, in case it's conf It was [a good learning exercise](https://nolanlawson.com/2023/12/02/lets-learn-how-modern-javascript-frameworks-work-by-building-one/), and it reduced the bundle size quite a bit to switch from Svelte to a custom framework. Plus, `emoji-picker-element` no longer needs to keep up with breaking changes in Svelte or the tools in the Svelte ecosystem (e.g. Rollup and Jest plugins). +### What are some of the quirks of the custom framework? + +The framework mostly gets the job done, but I took a few shortcuts since we didn't need all the possible bells and whistles. Here is a brief description. + +First, all of the DOM nodes and update functions for those nodes are kept in-memory via a WeakMap pointing at the `state`. There's one `state` per instance of the `Picker.js` Svelte-esque component. So when the instance is GC'ed, everything related to the DOM and update functions should be GC'ed. + +Second, I took a shortcut, which is that all DOM nodes and update functions are keyed off of 1) the unique tokens for the tag template literal plus 2) a unique `key` from the `map` function (if it exists). These are only GC'ed when the whole `state` is GC'ed. So in the worst case, every DOM node for every emoji in the picker is kept in memory (e.g. if you click on every tab button), but this seemed like a reasonable tradeoff for simplicity, plus the perf improvement of avoiding re-rendering the same node when it's unchanged (this is especially important if the reactivity system is kind of chatty, and is constantly setting the same arrays over and over – the framework just notices that all the `children` are the same objects and doesn't re-render). This also works because the `map`ed DOM nodes are not highly dynamic. + +Third, all refs and event listeners are only bound once – this just happens to work since most of the event listeners are hoisted (delegated) anyway. + +Fourth, `map`ped iterations without a single top-level element are unsupported – this makes updating iterations much easier, since I can just use `Element.replaceChildren()` instead of having to keep bookmark comment nodes or something. + +Fifth, the reactivity system is really bare-bones and doesn't check for cycles or avoid wasteful re-renderings or anything. So there's a lot of guardrails to avoid setting the same object over and over to avoid infinite cycles or to avoid excessive re-renders. + +Sixth, I tried to get fine-grained reactivity working but gave up, so basically the whole top-level `PickerTemplate.js` function is executed over and over again anytime anything changes. So there are guardrails in place to make sure this isn't expensive (e.g. the caching mechanisms described above). + +There's also a long tail of things that aren't supported in the HTML parser, like funky characters like `<` and `=` inside of text nodes, which could confuse the parser (so I just don't support them). + +Also, it's assumed that we're using some kind of minifier for the HTML tagged template literals – it would be annoying to have to author `PickerTemplate.js` without any whitespace. So the parser doesn't support comments since those are assumed to be stripped out anyway. + +That's about it, there are probably bugs in the framework if you tried to use it for something other than `emoji-picker-element`, but that's fine – it only needs to support one component anyway. + ### Why are the built JS files at the root of the project? When publishing to npm, we want people to be able to do e.g. `import Picker from 'emoji-picker-element/picker'`. The only way to get that is to put `picker.js` at the top level. @@ -72,4 +94,4 @@ I could also build a `pkg/` directory and copy the `package.json` into it (this ### Why build two separate bundles? -`picker.js` and `database.js` are designed to be independentally `import`-able. The only way to do this correctly with the right behavior from bundlers like Rollup and Webpack is to create two separate files. Otherwise the bundler would not be able to tree-shake `picker` from `database`. +`picker.js` and `database.js` are designed to be independently `import`-able. The only way to do this correctly with the right behavior from bundlers like Rollup and Webpack is to create two separate files. Otherwise the bundler would not be able to tree-shake `picker` from `database`. From 49cadcfeec37ec5073b6033b4d4f857e74d7a686 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Sat, 16 Dec 2023 13:29:44 -0800 Subject: [PATCH 099/113] fix: better docs and debuggability --- CONTRIBUTING.md | 4 ++-- src/picker/components/Picker/framework.js | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 066fa8ef..5dc1912e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,9 +68,9 @@ up with breaking changes in Svelte or the tools in the Svelte ecosystem (e.g. Ro The framework mostly gets the job done, but I took a few shortcuts since we didn't need all the possible bells and whistles. Here is a brief description. -First, all of the DOM nodes and update functions for those nodes are kept in-memory via a WeakMap pointing at the `state`. There's one `state` per instance of the `Picker.js` Svelte-esque component. So when the instance is GC'ed, everything related to the DOM and update functions should be GC'ed. +First, all the DOM nodes and update functions for those nodes are kept in-memory via a `WeakMap` where the key is the `state`. There's one `state` per instance of the `Picker.js` Svelte-esque component. So when the instance is GC'ed, everything related to the DOM and update functions should be GC'ed. (The exception is the global `parseCache`, which only contains the clone-able `template` and bindings for each unique `tokens` array, which is unique per `html` tag template literal. These templates/bindings never changes per component instance, so it makes sense to just parse once and cache them forever, in case the `` element itself is constantly unmounted and re-created.) -Second, I took a shortcut, which is that all DOM nodes and update functions are keyed off of 1) the unique tokens for the tag template literal plus 2) a unique `key` from the `map` function (if it exists). These are only GC'ed when the whole `state` is GC'ed. So in the worst case, every DOM node for every emoji in the picker is kept in memory (e.g. if you click on every tab button), but this seemed like a reasonable tradeoff for simplicity, plus the perf improvement of avoiding re-rendering the same node when it's unchanged (this is especially important if the reactivity system is kind of chatty, and is constantly setting the same arrays over and over – the framework just notices that all the `children` are the same objects and doesn't re-render). This also works because the `map`ed DOM nodes are not highly dynamic. +Second, I took a shortcut, which is that all unique (non-`