Skip to content

[reconciler] Mark moved fibers with placement flag granularly and cache host sibling lookups during commit #33048

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

vasylenkoval
Copy link

Summary

This PR includes 2 small optimizations:

  • Render phase: Mark updated moved fibers with Placement flag granularly by computing the longest increasing subsequence (LIS) of their old indices and finding out which fibers are not in LIS.
  • Commit phase: Cache host sibling lookups per parent to avoid repeated tree traversals on consecutive inserts.

Results

Here are the js-framework-benchmark results comparing react-hooks-placement-opt vs react-hooks built locally against the vanilla JS implementation. The lower score is better.

This table shows significant benchmark score improvements in swap rows and create many rows. While I understand that these two metrics are edge cases and are specifically designed to stress the implementation, I still wanted to present my findings because the code changes turned out to be relatively minor. My main goal was to get familiar with some of the codebase while doing a fun project, however, if there is interest I am happy to get this over the finish line.

test-results

Motivation

I was looking at reconcileChildrenArray function in react-reconciler and found something that made me curious.

Suppose we have a list of nodes:

[A, B, C, D, E, F, G]

That got re-arranged in the following way:

[A, F (moved), B, C, D, E, G]

Ideally, only F should be marked as moved (Placement flag). However, the reconciler outputs this:

[A, F (moved), B (moved), C (moved), D (moved), E (moved), G]

Only A and G remain in place. I found this interesting and dug further because I had an assumption that React would try to derive the least amount of dom operations needed. It seemed to me as if there was a code complexity tradeoff being made and I was curious if there was a way to make it granular while maintaining runtime performance.

This behavior comes from the placeChild helper:

  function placeChild(
    newFiber: Fiber,
    lastPlacedIndex: number,
    newIndex: number,
  ): number {
     ...
    const current = newFiber.alternate;
    if (current !== null) {
      const oldIndex = current.index;
      if (oldIndex < lastPlacedIndex) {
        // This is a move.
        newFiber.flags |= Placement | PlacementDEV;
        return lastPlacedIndex;
      } else {
        // This item can stay in place.
        return oldIndex;
      }
     ...
  }

The above results in more work in commitPlacement, where for every "placed" node it will look up the closest "non-placed" stable sibling with dom (in getHostSibling) and insert each node before it. In our example for each "placed" node (F, B, C, D, E) React will traverse the fiber tree to lookup G repeatedly and call insertBefore to place it before G.

This is how this would look like in the browser when only one item is moved back:
browser

The fact that getHostSibling was constantly looking up stable host nodes and had to go past pending inserts also led me to consider that it could be cached to prevent potential deep traversals that result in the same output.

For example, if one was rendering a table and then populated the table rows, for each row in this table getHostSibling will be called and traverse the entire list of siblings in front but return null each time.

Proposed improvements

Granular placement of updated fiber nodes

It's possible to derive the maximum number of updated nodes that can stay in place by looking at their indices in the old list and identifying which ones do not belong to the longest increasing subsequence when laid out in the new list order. An increasing subsequence of old indices in this case means those elements maintained their relative order.

If we look again at the previous example:

[A, B, C, D, E, F, G] -> [A, F (moved), B, C, D, E, G]

And take the old indices (alternate indices) of all updated nodes in the previous list:

[0 (A), 5 (F), 1 (B), 2 (C), 3 (D), 4 (E), 6 (G)]

After we find the LIS it results in the following indices:

[0 (A), 1 (B), 2 (C), 3 (D), 4 (E), 6 (G)]

Indices not in LIS:

[5 (F)] - this node was moved

We can proceed to mark F with the Placement flag and all other updated nodes can remain in place.

Finding nodes not in LIS:

The LIS is computed using a greedy patience sort, conceptually it can be visualized like this:
Screenshot 2025-04-19 at 6 47 09 PM
Slide from here.

The time complexity is O(n) in the case of all numbers increasing or decreasing (the most common cases) and O(N log N) in the case of completely random reordering of all nodes in the list.

The algorithm in this PR is modified to fit the exact use case of finding which entry is not in LIS.

Caching of getHostSibling

Let's say we have a list that originally did not have any rows and then rendered the following:

<List>
  <Row key='A' value='A'>
  <Row key='B' value='B'>
  <Row key='C' value='C'>
  <Row key='D' value='D'>
  <Row key='E' value='E'>
<List>

When committing row placement the current implementation will:

  • Look at A and start searching for the next sibling that A can be placed before
  • Eventually traverse the entire list resulting in null (all nodes are new).

If we maintain a cache per parent node (in this case List), we can keep track of the last node that was placed and what stateNode it was placed before.

Then when processing B in commitPlacement we can:

  • Reference the cache by parent (return)
  • Find out that the last placed node was A and that it was placed before null (end of list).
  • We verify that A is our direct previous sibling, and skip doing the traversal because we know it would result in the same host sibling.

How did you test this change?

  1. Ran the existing test suite
  2. Ran the js-framework-benchmark on the modified version of React without issues
  3. Introduced a test to verify reordering still works as expected in both regular and iterator versions of reconcileChildrenArray
  4. Added a test for a helper that computes what is not in LIS

Comment on lines 1931 to 1933
resetPlacementCommitCache();
commitMutationEffectsOnFiber(finishedWork, root, committedLanes);
resetPlacementCommitCache();
Copy link
Author

Choose a reason for hiding this comment

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

I was not sure how to handle the case of commit potentially failing and not getting a chance to reset the cache, but also did not want the cache to persist after the commit is done. So I decided to wrap commitMutationEffectsOnFiber in two calls just in case. Maybe there is a place higher in the call stack that is better.

Copy link
Author

Choose a reason for hiding this comment

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

Could be in flushMutationEffects since it has finally block

Copy link
Contributor

@josephsavona josephsavona left a comment

Choose a reason for hiding this comment

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

This is really exciting! The LIS algorithm makes sense to me. Caching the lookups for where to insert is also an obvious win. I'm not sure about the caching approach there — it would be really nice if that was purely local (passed as an argument and kept on the stack) vs a module local that has to be reset. We'd also want to feature flag this for test purposes and to help measure performance impact in practice.

But we should definitely proceed with this in some form. cc @jbrown215 had also been looking at list reconciliation performance, can you follow-up more?

}

// Retrieves or creates the placement commit cache entry for a given fiber.
export function getPlacementCommitCacheEntry(
Copy link
Contributor

Choose a reason for hiding this comment

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

doesn't need to be exported?

@react-sizebot
Copy link

Comparing: 5dc00d6...73f2f6d

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 6.68 kB 6.68 kB = 1.83 kB 1.83 kB
oss-stable/react-dom/cjs/react-dom-client.production.js +0.57% 527.72 kB 530.71 kB +0.63% 93.07 kB 93.65 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 6.69 kB 6.69 kB = 1.83 kB 1.83 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js +0.47% 633.34 kB 636.33 kB +0.56% 111.25 kB 111.87 kB
facebook-www/ReactDOM-prod.classic.js +0.45% 671.13 kB 674.13 kB +0.49% 117.70 kB 118.27 kB
facebook-www/ReactDOM-prod.modern.js +0.45% 661.41 kB 664.41 kB +0.49% 116.14 kB 116.71 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable-semver/react-art/cjs/react-art.production.js +0.96% 306.67 kB 309.61 kB +1.09% 51.76 kB 52.32 kB
oss-stable/react-art/cjs/react-art.production.js +0.96% 306.75 kB 309.69 kB +1.09% 51.78 kB 52.35 kB
oss-stable-semver/react-test-renderer/cjs/react-test-renderer.production.js +0.92% 319.10 kB 322.04 kB +1.01% 55.39 kB 55.95 kB
oss-stable/react-test-renderer/cjs/react-test-renderer.production.js +0.92% 319.17 kB 322.12 kB +1.01% 55.41 kB 55.97 kB
oss-experimental/react-test-renderer/cjs/react-test-renderer.production.js +0.92% 319.35 kB 322.29 kB +1.01% 55.45 kB 56.01 kB
facebook-react-native/react-test-renderer/cjs/ReactTestRenderer-prod.js +0.88% 336.14 kB 339.09 kB +0.98% 58.25 kB 58.82 kB
oss-experimental/react-art/cjs/react-art.production.js +0.85% 347.64 kB 350.58 kB +0.97% 58.63 kB 59.20 kB
facebook-react-native/react-test-renderer/cjs/ReactTestRenderer-profiling.js +0.82% 360.90 kB 363.85 kB +0.94% 61.67 kB 62.25 kB
react-native/implementations/ReactNativeRenderer-prod.js +0.79% 371.85 kB 374.80 kB +0.94% 63.93 kB 64.53 kB
facebook-www/ReactART-prod.modern.js +0.78% 378.32 kB 381.28 kB +0.87% 63.30 kB 63.85 kB
oss-stable-semver/react-reconciler/cjs/react-reconciler.production.js +0.76% 402.11 kB 405.18 kB +0.91% 64.78 kB 65.37 kB
react-native/implementations/ReactNativeRenderer-prod.fb.js +0.76% 385.62 kB 388.57 kB +0.89% 66.54 kB 67.13 kB
oss-stable/react-reconciler/cjs/react-reconciler.production.js +0.76% 402.14 kB 405.21 kB +0.91% 64.81 kB 65.39 kB
facebook-www/ReactART-prod.classic.js +0.76% 388.31 kB 391.27 kB +0.85% 64.97 kB 65.52 kB
react-native/implementations/ReactNativeRenderer-profiling.js +0.74% 399.80 kB 402.75 kB +0.85% 68.01 kB 68.59 kB
react-native/implementations/ReactNativeRenderer-profiling.fb.js +0.71% 413.74 kB 416.67 kB +0.83% 70.64 kB 71.22 kB
oss-stable-semver/react-reconciler/cjs/react-reconciler.profiling.js +0.70% 431.33 kB 434.37 kB +0.83% 68.78 kB 69.35 kB
oss-stable/react-reconciler/cjs/react-reconciler.profiling.js +0.70% 431.36 kB 434.40 kB +0.83% 68.80 kB 69.38 kB
oss-experimental/react-reconciler/cjs/react-reconciler.production.js +0.64% 474.73 kB 477.78 kB +0.75% 75.80 kB 76.37 kB
facebook-www/ReactReconciler-prod.modern.js +0.61% 498.12 kB 501.17 kB +0.70% 79.50 kB 80.05 kB
facebook-www/ReactReconciler-prod.classic.js +0.60% 508.43 kB 511.48 kB +0.71% 81.11 kB 81.68 kB
react-native/implementations/ReactFabric-prod.js +0.59% 364.35 kB 366.51 kB +0.60% 62.82 kB 63.20 kB
oss-stable-semver/react-dom/cjs/react-dom-client.production.js +0.57% 527.59 kB 530.59 kB +0.63% 93.04 kB 93.62 kB
oss-stable/react-dom/cjs/react-dom-client.production.js +0.57% 527.72 kB 530.71 kB +0.63% 93.07 kB 93.65 kB
react-native/implementations/ReactFabric-prod.fb.js +0.57% 381.79 kB 383.95 kB +0.59% 65.90 kB 66.29 kB
oss-stable-semver/react-test-renderer/cjs/react-test-renderer.development.js +0.57% 569.44 kB 572.66 kB +0.62% 91.83 kB 92.40 kB
oss-experimental/react-test-renderer/cjs/react-test-renderer.development.js +0.57% 569.47 kB 572.69 kB +0.63% 91.84 kB 92.41 kB
oss-stable/react-test-renderer/cjs/react-test-renderer.development.js +0.57% 569.52 kB 572.74 kB +0.62% 91.85 kB 92.43 kB
oss-experimental/react-reconciler/cjs/react-reconciler.profiling.js +0.56% 538.69 kB 541.72 kB +0.68% 84.68 kB 85.25 kB
oss-stable-semver/react-art/cjs/react-art.development.js +0.56% 570.40 kB 573.60 kB +0.65% 91.07 kB 91.66 kB
oss-stable/react-art/cjs/react-art.development.js +0.56% 570.48 kB 573.68 kB +0.65% 91.10 kB 91.69 kB
react-native/implementations/ReactFabric-profiling.js +0.55% 392.26 kB 394.42 kB +0.61% 66.79 kB 67.19 kB
facebook-www/ReactTestRenderer-dev.modern.js +0.55% 587.46 kB 590.68 kB +0.64% 94.90 kB 95.50 kB
facebook-www/ReactTestRenderer-dev.classic.js +0.55% 587.47 kB 590.69 kB +0.64% 94.90 kB 95.50 kB
facebook-react-native/react-dom/cjs/ReactDOMClient-prod.js +0.54% 551.91 kB 554.90 kB +0.61% 97.09 kB 97.68 kB
facebook-react-native/react-dom/cjs/ReactDOMProfiling-prod.js +0.54% 557.42 kB 560.41 kB +0.60% 98.18 kB 98.77 kB
facebook-react-native/react-test-renderer/cjs/ReactTestRenderer-dev.js +0.54% 600.73 kB 603.96 kB +0.62% 96.43 kB 97.03 kB
oss-stable-semver/react-dom/cjs/react-dom-profiling.profiling.js +0.53% 561.25 kB 564.23 kB +0.59% 98.00 kB 98.58 kB
oss-stable/react-dom/cjs/react-dom-profiling.profiling.js +0.53% 561.37 kB 564.35 kB +0.59% 98.03 kB 98.61 kB
react-native/implementations/ReactFabric-profiling.fb.js +0.53% 409.97 kB 412.13 kB +0.59% 70.01 kB 70.42 kB
facebook-react-native/react-dom/cjs/ReactDOMClient-profiling.js +0.51% 580.14 kB 583.12 kB +0.58% 101.07 kB 101.65 kB
facebook-react-native/react-dom/cjs/ReactDOMProfiling-profiling.js +0.51% 586.08 kB 589.06 kB +0.57% 102.23 kB 102.81 kB
oss-stable-semver/react-reconciler/cjs/react-reconciler.development.js +0.49% 663.81 kB 667.06 kB +0.55% 104.99 kB 105.57 kB
oss-stable/react-reconciler/cjs/react-reconciler.development.js +0.49% 663.84 kB 667.08 kB +0.55% 105.02 kB 105.59 kB
react-native/implementations/ReactNativeRenderer-dev.js +0.48% 660.31 kB 663.51 kB +0.53% 107.04 kB 107.61 kB
oss-experimental/react-art/cjs/react-art.development.js +0.48% 661.18 kB 664.38 kB +0.55% 104.24 kB 104.81 kB
react-native/implementations/ReactNativeRenderer-dev.fb.js +0.47% 676.47 kB 679.67 kB +0.53% 109.78 kB 110.37 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js +0.47% 633.34 kB 636.33 kB +0.56% 111.25 kB 111.87 kB
oss-experimental/react-dom/cjs/react-dom-unstable_testing.production.js +0.46% 647.75 kB 650.74 kB +0.53% 114.84 kB 115.46 kB
facebook-www/ReactDOM-prod.modern.js +0.45% 661.41 kB 664.41 kB +0.49% 116.14 kB 116.71 kB
facebook-www/ReactART-dev.modern.js +0.45% 714.55 kB 717.75 kB +0.52% 111.16 kB 111.73 kB
facebook-www/ReactDOM-prod.classic.js +0.45% 671.13 kB 674.13 kB +0.49% 117.70 kB 118.27 kB
facebook-www/ReactDOMTesting-prod.modern.js +0.44% 675.81 kB 678.81 kB +0.47% 119.81 kB 120.37 kB
facebook-www/ReactART-dev.classic.js +0.44% 724.05 kB 727.25 kB +0.52% 112.89 kB 113.48 kB
facebook-www/ReactDOMTesting-prod.classic.js +0.44% 685.53 kB 688.53 kB +0.47% 121.37 kB 121.94 kB
oss-experimental/react-dom/cjs/react-dom-profiling.profiling.js +0.43% 699.33 kB 702.31 kB +0.50% 121.10 kB 121.71 kB
oss-experimental/react-reconciler/cjs/react-reconciler.development.js +0.41% 789.25 kB 792.49 kB +0.46% 123.10 kB 123.67 kB
facebook-www/ReactDOM-profiling.modern.js +0.40% 736.46 kB 739.44 kB +0.46% 126.47 kB 127.05 kB
facebook-www/ReactDOM-profiling.classic.js +0.40% 744.50 kB 747.48 kB +0.46% 127.78 kB 128.36 kB
facebook-www/ReactReconciler-dev.modern.js +0.39% 831.20 kB 834.44 kB +0.45% 128.84 kB 129.43 kB
facebook-www/ReactReconciler-dev.classic.js +0.39% 840.40 kB 843.65 kB +0.46% 130.58 kB 131.18 kB
react-native/implementations/ReactFabric-dev.js +0.36% 651.33 kB 653.68 kB +0.37% 105.63 kB 106.02 kB
react-native/implementations/ReactFabric-dev.fb.js +0.35% 670.36 kB 672.71 kB +0.35% 108.80 kB 109.18 kB
oss-stable-semver/react-dom/cjs/react-dom-client.development.js +0.33% 971.81 kB 975.05 kB +0.36% 162.97 kB 163.55 kB
oss-stable/react-dom/cjs/react-dom-client.development.js +0.33% 971.93 kB 975.17 kB +0.36% 162.99 kB 163.57 kB
oss-stable-semver/react-dom/cjs/react-dom-profiling.development.js +0.33% 988.25 kB 991.49 kB +0.35% 165.81 kB 166.39 kB
oss-stable/react-dom/cjs/react-dom-profiling.development.js +0.33% 988.37 kB 991.62 kB +0.35% 165.84 kB 166.42 kB
facebook-react-native/react-dom/cjs/ReactDOMClient-dev.js +0.32% 1,007.71 kB 1,010.95 kB +0.35% 168.86 kB 169.45 kB
facebook-react-native/react-dom/cjs/ReactDOMProfiling-dev.js +0.32% 1,024.04 kB 1,027.28 kB +0.33% 171.72 kB 172.29 kB
oss-experimental/react-dom/cjs/react-dom-client.development.js +0.28% 1,148.38 kB 1,151.62 kB +0.30% 190.96 kB 191.54 kB
oss-experimental/react-dom/cjs/react-dom-profiling.development.js +0.28% 1,164.77 kB 1,168.02 kB +0.30% 193.81 kB 194.40 kB
oss-experimental/react-dom/cjs/react-dom-unstable_testing.development.js +0.28% 1,164.92 kB 1,168.17 kB +0.31% 194.66 kB 195.25 kB
facebook-www/ReactDOM-dev.modern.js +0.27% 1,201.28 kB 1,204.52 kB +0.30% 198.23 kB 198.82 kB
facebook-www/ReactDOM-dev.classic.js +0.27% 1,210.42 kB 1,213.67 kB +0.31% 200.00 kB 200.61 kB
facebook-www/ReactDOMTesting-dev.modern.js +0.27% 1,217.82 kB 1,221.06 kB +0.29% 202.00 kB 202.59 kB
facebook-www/ReactDOMTesting-dev.classic.js +0.26% 1,226.96 kB 1,230.20 kB +0.30% 203.71 kB 204.32 kB

Generated by 🚫 dangerJS against 73f2f6d

@vasylenkoval
Copy link
Author

vasylenkoval commented Apr 29, 2025

This is really exciting! The LIS algorithm makes sense to me. Caching the lookups for where to insert is also an obvious win. I'm not sure about the caching approach there — it would be really nice if that was purely local (passed as an argument and kept on the stack) vs a module local that has to be reset. We'd also want to feature flag this for test purposes and to help measure performance impact in practice.

But we should definitely proceed with this in some form. cc @jbrown215 had also been looking at list reconciliation performance, can you follow-up more?

Thank you for your feedback @josephsavona! I’ve considered passing this cache through an argument, but it required a bit of drilling so I was hesitant about it spilling. As far as I can tell the closest place where it can be instantiated is here:


function recursivelyTraverseMutationEffects(
  root: FiberRoot,
  parentFiber: Fiber,
  lanes: Lanes,
) {
  // Deletions effects can be scheduled on any fiber type. They need to happen
  // before the children effects have fired.
  const deletions = parentFiber.deletions;
  if (deletions !== null) {
    for (let i = 0; i < deletions.length; i++) {
      const childToDelete = deletions[i];
      commitDeletionEffects(root, parentFiber, childToDelete);
    }
  }

  if (
    parentFiber.subtreeFlags &
    (enablePersistedModeClonedFlag ? MutationMask | Cloned : MutationMask)
  ) {
    // <— instantiate commit placement cache here
    let child = parentFiber.child;
    while (child !== null) {
      commitMutationEffectsOnFiber(child, root, lanes);
      child = child.sibling;
    }
  }
}

Then it can be passed through this chain of calls as an argument:

commitMutationEffectsOnFiber -> commitReconciliationEffects -> commitHostPlacement -> commitPlacement -> getHostSiblingCached

This way it would have an automatic cleanup, as you mentioned, and wouldn't need to use a Map. Please let me know if that's how you envision it, I am happy to give it a stab. Also, I can definitely add 2 feature flags, let me take a look into how that's done.

…t cache. Also refactor placement commit cache to be passed through arguments.
@vasylenkoval
Copy link
Author

@josephsavona @jbrown215 Added 2 feature flags:

  • enableGranularChildrenPlacement
  • enablePlacementCommitCache

Should these flags be initialized with false or __EXPERIMENTAL__?

Also, took a stab at refactoring the getHostSibling caching to use a variable that's drilled through arguments from the closest function that iterates through children during commit. After re-running the js-framework-benchmark locally with FF on and off it appears to be a bit slower.

Please let me know your thoughts!

refactor

@josephsavona
Copy link
Contributor

Overall looks good. I'd love to get review from someone else who's working in the runtime more.

After re-running the js-framework-benchmark locally with FF on and off it appears to be a bit slower.

The second set of numbers looked nearly identical to the first? Can you share which things got slower specifically when switching to passing the cache via arguments instead of a module local?

@sebmarkbage
Copy link
Collaborator

Before exploring something like this we really need to add a benchmark for the common cases. The most common case that the reconciliation algorithm is applied is in a large tree where something near the root and prop drills all the way down. This means that each set of children has to be diff:ed but there will be no change. In this case it's common to have even tens of thousand of elements each of which children set is diffed only to find out that there's no change.

The current algorithm is hyper optimized around no-change.

Reordering thousands of items in a flat list is extremely rare and even when it is applied, it should probably be windowed instead. Even if it's not, then it's still not that slow. But diffing thousand items by prop drilling from the root happens a lot.

Similarly rendering long lists of deep children that render null is extremely uncommon and we need to make sure that adding caching doesn't slow down the common case for no apparent benefit.

@vasylenkoval
Copy link
Author

vasylenkoval commented May 1, 2025

@sebmarkbage Great points! Thank you for taking a look!

For reconciliation, the goal is definitely to preserve the common case performance (re-render with no change) while introducing additional benefits like handling reordering interactions granularly. However, you are right that js-framework-benchmark might not be able to capture the impact on the common case well enough.

Similarly rendering long lists of deep children that render null is extremely uncommon and we need to make sure that adding caching doesn't slow down the common case for no apparent benefit.

When committing placement, having to go deep into a stable sibling just to find out that it renders null is definitely uncommon. To be clear, I think caching getHostSibling is useful for simply cutting down the redundant sibling lookups (on the same level) when rendering new children. Since every child has to walk till the end of the list until it concludes that there are no stable host siblings. One child doing that is enough to shortcut the rest of them.

I found it interesting that the number of sibling visits in this case (rendering new children) is growing non-linearly with the number of children. For example, create many rows in that benchmark had to visit siblings effectively 50,005,000 times instead of 10,000, hence the big jump in scores. It seemed like a good idea to trade a bit of memory to guarantee it’s linear for all cases given that the change seemed trivial.

Overall, I do agree that the common case should be proven to be the same or faster when considering this. Having a benchmark that measures the common case you are describing might be more valuable than all of the above. Let me take a look into bootstrapping that.

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

Successfully merging this pull request may close these issues.

5 participants