Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(kcodeblock): improve matching perf #2562

Merged
merged 24 commits into from
Jan 16, 2025
Merged

fix(kcodeblock): improve matching perf #2562

merged 24 commits into from
Jan 16, 2025

Conversation

Justineo
Copy link
Contributor

@Justineo Justineo commented Jan 9, 2025

Summary

KM-888

An earlier optimization was implemented in #2558, where line matching now uses a Set instead of looping through an array, reducing the operation’s time complexity to O(1).

This PR introduces further performance enhancements to the <KCodeBlock> component while addressing multiple bugs.

Key improvements

  1. Correctly escape HTML and RegExp

    Resolved that when highlighting matched characters in filtered code:

    • It did not escape HTML, potentially causing incorrect output.
    • It did not escape when creating RegExp in exact match mode, eg. . was actually highlighting all characters.
  2. Reduce redundant updateMatchingLineNumbers calls

    Prevented calls to updateMatchingLineNumbers when isShowingFilteredCode changes, avoiding unnecessary processing.

  3. Optimized line matching logic

    • Previously, for each match index, the implementation split all text from the beginning to pos or match.index in order to get the line number for each match, leading to high computational costs (O(n*m), where n is code length and m is the number of matches).

    • The new approach precomputes line offsets and uses binary search to determine line numbers, significantly reducing overhead.

  4. Support for multi-line regular expressions

    Fixed a bug preventing regular expression patterns from matching across multiple lines.

  5. Optimized character highlighting in filter mode

    Escaping HTML for highlight output now involves additional work compared to the old implementation, but further optimizations have made the process significantly faster in most cases.

    • Efficient Highlight Processing: The previous approach split the filtered code into individual lines, highlighting each one separately. It not only failed to properly escape the input code but was also creating a new RegExp instance during every iteration. The new approach processes the entire code at once, using RegExp.prototype.exec to identify matches and perform both matching and escaping in a single pass.

    • Selective escaping: Escapes only < and & since the output HTML is isolated (v-html), reducing escape complexity (compared to escaping a broader set of characters like <, >, ', ", and &).

    • Fast path for non-escaping cases: Introduced a quick pre-check using /[<&]/ to determine if escaping is necessary, enabling a fast path for cases without special characters. The new escapeIfNeeded function adopts this optimized approach, replacing the original escape implementation that executed upon component render. The previous method, which applied replaceAll to the entire input for each character in the escape list, was sub-optimal. While the performance difference is negligible for short inputs, the new approach significantly improves highlighting speed in most use cases while maintaining correctness.

    • Merging adjacent matches: For short queries or patterns like /./ that generate many consecutive matches, adjacent match marks are merged. This reduces the number of extra elements and accelerates both calculation and rendering. This is achieved by wrapping the RegExp in a grouping construct (:...)+. This improves speed for both calculation and rendering.

  6. Significant improvement in Rendering Performance

    Following the optimization of the matching and highlighting algorithms, the primary bottleneck shifted to the rendering process. Using Chrome’s Performance Profiler, we identified that the most time-consuming operation was “Recalculate Style.” To address this, we replaced display: grid, which can be inefficient for a large number of elements, with a more static layout. Additionally, content-visibility was applied for line number elements. These changes resulted in a substantial boost in rendering performance.

    content-visibility: auto hides elements from the render tree and only renders them when they scroll into view or when their metrics are accessed programmatically (e.g., getBoundingClientRect, offsetHeight, etc.). However, this can introduce unpredictable issues.

    One issue I encountered involves the interaction between our current DOM structure and 1Password’s heuristics. 1Password attempts to identify labels associated with form inputs and iterates through all line number elements and retrieves their layout information one by one. This process forces the browser to repeatedly calculate layouts for each previously hidden element styled with content-visibility: auto.

    In the case of a code input with a large number of lines, this behavior causes the page to freeze when focusing on the search input within the code block for the first time.

    Ultimately, I introduced a new dependency, virtua, which provides virtual scrolling primitives to fully resolve the issue. And now the bottleneck switched back to the matching algorithm but it's already fast enough for most our use cases.

Benchmarks

Input code:

const code = `{
  "compilerOptions": {
    "markup": "<h1>true</h1>",
    "target": "es2020",
    "module": "esnext",
    "moduleResolution": "node",
    "allowUnreachableCode": false,
    "exactOptionalPropertyTypes": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitReturns": true,
    "noUncheckedIndexedAccess": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "strict": true,
    "jsx": "preserve"
  },
  "include": [
    "./src",
    "./types",
    "./particularly-long-value-that-will-inadvertently-cause-scrolling-for-narrower-containers"
  ]
}
`.repeat(100)
Task Query Fast path
Doesn't contain < or &
Original
(Ops/s)
Optimized
(Ops/s)
🚀
Highlight "true" 8,015 10,778 1.3×
Highlight " " 1,923 2,730 1.4×
Highlight "true" 9,460 31,690 3.3×
Highlight " " 2,045 6,996 3.4×
Highlight /true/ 8,323 10,982 1.3×
Highlight /./ 505 41,814 82.8×
Highlight /true/ 9,460 32,103 3.4×
Highlight /./ 533 64,362 120.8×
Match "true" - 9,135 462,782 50.7×
Match " " - 790 441,588 559.0×
Match /true/ - 8,840 250,267 28.3×
Match /./ - 124 6,016 48.5×

† The original implementation is too slow so only used 1/10 of the input code.

Copy link

netlify bot commented Jan 9, 2025

Deploy Preview for kongponents-sandbox ready!

Name Link
🔨 Latest commit 5a061a6
🔍 Latest deploy log https://app.netlify.com/sites/kongponents-sandbox/deploys/678937154e1fa100099db11b
😎 Deploy Preview https://deploy-preview-2562--kongponents-sandbox.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

Copy link

netlify bot commented Jan 9, 2025

Deploy Preview for kongponents ready!

Name Link
🔨 Latest commit 5a061a6
🔍 Latest deploy log https://app.netlify.com/sites/kongponents/deploys/67893715a116d4000826fc52
😎 Deploy Preview https://deploy-preview-2562--kongponents.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

@Justineo Justineo marked this pull request as draft January 9, 2025 16:57
@Justineo Justineo marked this pull request as ready for review January 9, 2025 17:41
src/components/KCodeBlock/KCodeBlock.vue Outdated Show resolved Hide resolved
.vscode/settings.json Show resolved Hide resolved
src/utilities/codeBlockHelpers.ts Outdated Show resolved Hide resolved
src/utilities/codeBlockHelpers.ts Outdated Show resolved Hide resolved
src/utilities/codeBlockHelpers.ts Outdated Show resolved Hide resolved
src/utilities/codeBlockHelpers.ts Show resolved Hide resolved
src/utilities/codeBlockHelpers.cy.ts Outdated Show resolved Hide resolved
Copy link
Contributor

@johncowen johncowen left a comment

Choose a reason for hiding this comment

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

Thanks for digging into this! Looks great!

We spoke offline, and I mentioned I'm gonna have a play with this in place (we/kuma-gui originally reported a performance issues with KCodeBlock in Slack).

I had a relatively quick scan of the code, but I for one am happy you've done this with some significant care, so I'm pretty confident it'll be a big improvement judging by the description and the overall approach (also thanks taking the time with the detailed description!)

I left a couple of Q's mainly for myself, so let me know on those. I'll try and give this a spin in our application today and pop back later 👍 , could end up being after the weekend but hopefully today.

src/utilities/codeBlockHelpers.cy.ts Show resolved Hide resolved
src/utilities/codeBlockHelpers.cy.ts Show resolved Hide resolved
@johncowen
Copy link
Contributor

I'll try and give this a spin in our application today and pop back later 👍 , could end up being after the weekend but hopefully today.

P.S. But don't wait for me if you get an approval from elsewhere and want to go ahead and merge

@Justineo
Copy link
Contributor Author

Anyway, one tiny suggestion would be (maybe not for this PR): Do you think it would be worthwhile adding a debounce to the input field? Maybe even as a parameter to the component so the consumer of the component can choose whether they'd like to add a debounce or not (or maybe the other way around, add debounce by default, but give folks the option to turn it off)

The old implementation already denounced the query input with a delay of 150ms.

@johncowen
Copy link
Contributor

The old implementation already denounced the query input with a delay of 150ms.

Oh I see! Maybe we could increase it a bit? Say to 1000ms? Maybe a bit too much hassle but am I right in thinking we'd only want the debounce when there are less than say 3 characters in the field and after say 3 characters not bother debouncing? Not totally sure.

Anyway, was just a suggestion, if you like it maybe its for another PR, if not no bother 👍

@Justineo
Copy link
Contributor Author

A long debounce only makes it feel slow. I think current delay is find and I think in most cases it should be fast enough now. Also the speed depends on multiple factors, eg. Whether there are a lot of matches or does the code contain characters that need escaping, etc.

@johncowen
Copy link
Contributor

Yep no prob 👍

@adamdehaven
Copy link
Member

Couple of other things off the back of that that are maybe for @adamdehaven

Running pnpm build gave me this:

Screenshot 2025-01-13 at 09 51 40

Thanks @johncowen this has been fixed

src/components/KCodeBlock/KCodeBlock.vue Show resolved Hide resolved
const debouncedHandleSearchInputValue = debounce(handleSearchInputValue, 150)
// Use Lodash’s debounce function as it supports leading and trailing options.
// Adding `leading: true` to the options ensures that the search is triggered immediately when the user types the first character,
// mak
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maybe we should switch to lodash-es's debounce instead of providing our own one for the rest of the codebase in the future.

Copy link
Member

Choose a reason for hiding this comment

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

I think this would be ideal if you'd like to create a separate JIRA. We'd just need to check the usage in all components and ensure we aren't doing anything special (and that it's not exported)

@Justineo
Copy link
Contributor Author

Justineo commented Jan 15, 2025

In 58dec25 I added virtua to alleviate the potential performance issues of rendering a large number of line number elements. As a result, the search experience should now be much smoother. Read more here.

You can give it another try. @johncowen

@johncowen
Copy link
Contributor

In 58dec25 I added virtua to alleviate the potential performance issues of rendering a large number of line number elements. As a result, the search experience should now be much smoother. Read more #2562.

Ah perfect! So this is to only render the bits we need to see which would result in less DOM nodes on the page at one time?

Lemme take this one for a spin!

@johncowen
Copy link
Contributor

I had a quick look here at this next iteration, and I'm not totally sure if its any better or not. I couldn't see any DOM recycling happening when looking in Web Inspector, but I might have misunderstood where I should expect that. How can I validate the the DOM recycling/virtual scrolling thing is happening? Is it only in certain cases in certain areas? I'd understood it was only for the line numbers? Is that right?

@Justineo
Copy link
Contributor Author

I had a quick look here at this next iteration, and I'm not totally sure if its any better or not. I couldn't see any DOM recycling happening when looking in Web Inspector, but I might have misunderstood where I should expect that. How can I validate the the DOM recycling/virtual scrolling thing is happening? Is it only in certain cases in certain areas? I'd understood it was only for the line numbers? Is that right?

Where exactly did you look? You can verify this in the preview sandbox.

If DOM recycling is functioning properly, you should see the line elements updating as you scroll.

recycle.webm

@johncowen
Copy link
Contributor

Where exactly did you look?

In our application not the sandbox

If DOM recycling is functioning properly, you should see the line elements updating as you scroll.

Ah awesome yeah, that was what I was expecting to see but didn't 🤔.

Lemme try again in a bit

@Justineo
Copy link
Contributor Author

In our application not the sandbox

Did you reinstalled the preview package (and make sure there's no cache or sth)?

@johncowen
Copy link
Contributor

I previously had problems just installing via @kong/kongponents@pr-2562 and weirdly file linking (see above somewhere).

So I hack-ily checked out this branch separately, built it, copied the resulting source from /dist over into my applications node_modules folder, added a console.log() to the top of the copied source to verify I was running it, I also checked for several things that are added in this PR via Web Inspector (such as the autocomplete attribute). For this second iteration I also had to manually add the new deps you added to my applications package.json (i did this first so I didn't install over the hacky kongponents copy)

src/components/KCodeBlock/KCodeBlock.vue Show resolved Hide resolved
src/components/KCodeBlock/KCodeBlock.vue Outdated Show resolved Hide resolved
src/components/KCodeBlock/KCodeBlock.vue Outdated Show resolved Hide resolved
src/components/KCodeBlock/KCodeBlock.vue Outdated Show resolved Hide resolved
const debouncedHandleSearchInputValue = debounce(handleSearchInputValue, 150)
// Use Lodash’s debounce function as it supports leading and trailing options.
// Adding `leading: true` to the options ensures that the search is triggered immediately when the user types the first character,
// mak
Copy link
Member

Choose a reason for hiding this comment

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

I think this would be ideal if you'd like to create a separate JIRA. We'd just need to check the usage in all components and ensure we aren't doing anything special (and that it's not exported)

src/components/KCodeBlock/KCodeBlock.vue Show resolved Hide resolved
src/components/KCodeBlock/KCodeBlock.vue Outdated Show resolved Hide resolved
src/components/KCodeBlock/KCodeBlock.vue Show resolved Hide resolved
src/components/KCodeBlock/KCodeBlock.vue Outdated Show resolved Hide resolved
tsconfig.json Show resolved Hide resolved
Copy link
Member

@adamdehaven adamdehaven left a comment

Choose a reason for hiding this comment

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

lgtm

@Justineo Justineo merged commit 8525a29 into main Jan 16, 2025
10 checks passed
@Justineo Justineo deleted the feat/codeblock-perf branch January 16, 2025 23:11
kongponents-bot pushed a commit that referenced this pull request Jan 16, 2025
## [9.17.1](v9.17.0...v9.17.1) (2025-01-16)

### Bug Fixes

* **kcodeblock:** improve matching perf ([#2562](#2562)) ([8525a29](8525a29))
@kongponents-bot
Copy link
Collaborator

🎉 This PR is included in version 9.17.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

@kongponents-bot
Copy link
Collaborator

Preview package from this PR in consuming application

In consuming application project install preview version of kongponents generated by this PR:

@kong/kongponents@pr-2562

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants