Skip to content

Instantly share code, notes, and snippets.

@Rich-Harris
Last active September 24, 2023 20:08
Show Gist options
  • Star 18 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save Rich-Harris/aa3dc83d3d8a4e572d9be11aedc8c238 to your computer and use it in GitHub Desktop.
Save Rich-Harris/aa3dc83d3d8a4e572d9be11aedc8c238 to your computer and use it in GitHub Desktop.
first-class binding syntax

A modest proposal for a first-class destiny operator equivalent in Svelte components that's also valid JS.

Svelte 2 has a concept of computed properties, which are updated whenever their inputs change. They're powerful, if a little boilerplatey, but there's currently no place for them in Svelte 3.

This means that we have to use functions. Instead of this...

<!-- Svelte 2 -->
<h1>HELLO {NAME}!</h1>

<script>
  export default {
    data: () => ({ name: 'world' }),
    computed: {
      NAME: ({ name }) => name.toUpperCase()
    }
  };
</script>

...we have to do this:

<script>
  export let name = 'world';
  const NAME = () => name.toUpperCase();
</script>

<h1>HELLO {NAME()}!</h1>

But that has some serious downsides, especially when those functions depend on other function values. In the current.html example below, x_scale and y_scale are invoked twice per point. Each call to x_scale invokes min_x() and max_x(), and similarly for y_scale. That won't do.

Of course, the compiler could, in some cases, do some kind of automatic memoisation, if it was sufficiently smart. But such an approach would be riddled with caveats, and as a user I'm not sure I'd trust the compiler to do the right thing if I was staring at code that looks as though it's going to result in thousands of unnecessary function calls.

A syntax-abusing alternative

We can't implement the destiny operator, because it's not valid JS — parser, linters, typecheckers etc wouldn't be able to work with it. But there's a piece of syntax in JavaScript that we can use in its place: the labeled statement.

This is valid JS:

let name = 'world';
let NAME;

compute:NAME = name.toUpperCase();

See proposed.html below for a more complete example. The dependency graph is topologically sorted at compile time, and values that could have changed are recomputed once per update cycle.

Combining multiple computed values

Another benefit of this approach is that we could combine multiple computed values into a single block. As an alternative to this...

compute:min_x = Math.min(...points.map(p => p.x));
compute:max_x = Math.max(...points.map(p => p.x));
compute:min_y = Math.min(...points.map(p => p.y));
compute:max_y = Math.max(...points.map(p => p.y));

...we could do this, iterating over points once instead of mapping it four times:

compute: {
  min_x = Infinity; max_x = -Infinity; min_y = Infinity; max_y = -Infinity; // reset
  
  points.forEach(point => {
    if (point.x < min_x) min_x = point.x;
    if (point.x > max_x) max_x = point.x;
    if (point.y < min_y) min_y = point.y;
    if (point.y > max_y) max_y = point.y;
  });
}

Further thoughts

This could maybe work with sources as well — would this make sense?

import { todos } from './sources.js;'

let currentFilter = 'all';
let filteredTodos;

bind:filteredTodos = $todos.filter(currentFilter);
<svelte:window on:resize={handleResize}/>
<script>
import * as yootils from 'yootils';
import { onMount } from 'svelte';
export let points;
export let margins;
let container;
let width;
let height;
const min_x = () => Math.min(...points.map(p => p.x));
const max_x = () => Math.max(...points.map(p => p.x));
const min_y = () => Math.min(...points.map(p => p.y));
const max_y = () => Math.max(...points.map(p => p.y));
function x_scale() {
return yootils.linearScale(
[min_x(), max_x()],
[margins.left, width - margins.right]
);
}
function y_scale() {
return yootils.linearScale(
[min_y(), max_y()],
[height - margins.bottom, margins.top]
);
}
function handleResize() {
const { left, right, top, bottom } = container.getBoundingClientRect();
width = right - left;
height = bottom - top;
}
onMount(handleResize);
</script>
<svg ref:container>
{#each points as point}
<circle cx={x_scale()(point.x)} cy={y_scale()(point.y)} r=5/>
<text x={x_scale()(point.x)} cy={y_scale()(point.y)}>{point.label}</text>
{/each}
</svg>
<svelte:window on:resize={handleResize}/>
<script>
import * as yootils from 'yootils';
import { onMount } from 'svelte';
export let points;
export let margins;
let container;
let width = 0;
let height = 0;
let min_x, max_x, min_y, max_y, x_scale, y_scale;
compute:min_x = Math.min(...points.map(p => p.x));
compute:max_x = Math.max(...points.map(p => p.x));
compute:min_y = Math.min(...points.map(p => p.y));
compute:max_y = Math.max(...points.map(p => p.y));
compute:x_scale = yootils.linearScale(
[min_x, max_x],
[margins.left, width - margins.right]
);
compute:y_scale = yootils.linearScale(
[min_y, max_y],
[height - margins.bottom, margins.top]
);
function handleResize() {
const { left, right, top, bottom } = container.getBoundingClientRect();
width = right - left;
height = bottom - top;
}
onMount(handleResize);
</script>
<svg ref:container>
{#each points as point}
<circle cx={x_scale(point.x)} cy={y_scale(point.y)} r=5/>
<text x={x_scale(point.x)} cy={y_scale(point.y)}>{point.label}</text>
{/each}
</svg>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment