implement passing CSS custom properties to components (#6237)

pull/6271/head
Tan Li Hau 3 years ago committed by GitHub
parent ce55e10df5
commit 02b49a1bb4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -18,6 +18,7 @@ export default class InlineComponent extends Node {
bindings: Binding[] = [];
handlers: EventHandler[] = [];
lets: Let[] = [];
css_custom_properties: Attribute[] = [];
children: INode[];
scope: TemplateScope;
@ -46,6 +47,10 @@ export default class InlineComponent extends Node {
});
case 'Attribute':
if (node.name.startsWith('--')) {
this.css_custom_properties.push(new Attribute(component, this, scope, node));
break;
}
// fallthrough
case 'Spread':
this.attributes.push(new Attribute(component, this, scope, node));

@ -18,6 +18,7 @@ import { extract_names } from 'periscopic';
import mark_each_block_bindings from '../shared/mark_each_block_bindings';
import { string_to_member_expression } from '../../../utils/string_to_member_expression';
import SlotTemplate from '../../../nodes/SlotTemplate';
import { is_head } from '../shared/is_head';
type SlotDefinition = { block: Block; scope: TemplateScope; get_context?: Node; get_changes?: Node };
@ -63,6 +64,10 @@ export default class InlineComponentWrapper extends Wrapper {
}
});
this.node.css_custom_properties.forEach(attr => {
block.add_dependencies(attr.dependencies);
});
this.var = {
type: 'Identifier',
name: (
@ -146,6 +151,12 @@ export default class InlineComponentWrapper extends Wrapper {
}
}
const has_css_custom_properties = this.node.css_custom_properties.length > 0;
const css_custom_properties_wrapper = has_css_custom_properties ? block.get_unique_name('div') : null;
if (has_css_custom_properties) {
block.add_variable(css_custom_properties_wrapper);
}
const initial_props = this.slots.size > 0
? [
p`$$slots: {
@ -491,17 +502,64 @@ export default class InlineComponentWrapper extends Wrapper {
${munged_handlers}
`);
if (has_css_custom_properties) {
block.chunks.create.push(b`${css_custom_properties_wrapper} = @element("div");`);
block.chunks.hydrate.push(b`@set_style(${css_custom_properties_wrapper}, "display", "contents");`);
this.node.css_custom_properties.forEach(attr => {
const dependencies = attr.get_dependencies();
const should_cache = attr.should_cache();
const last = should_cache && block.get_unique_name(`${attr.name.replace(/[^a-zA-Z_$]/g, '_')}_last`);
if (should_cache) block.add_variable(last);
const value = attr.get_value(block);
const init = should_cache ? x`${last} = ${value}` : value;
block.chunks.hydrate.push(b`@set_style(${css_custom_properties_wrapper}, "${attr.name}", ${init});`);
if (dependencies.length > 0) {
let condition = block.renderer.dirty(dependencies);
if (should_cache) condition = x`${condition} && (${last} !== (${last} = ${value}))`;
block.chunks.update.push(b`
if (${condition}) {
@set_style(${css_custom_properties_wrapper}, "${attr.name}", ${should_cache ? last : value});
}
`);
}
});
}
block.chunks.create.push(b`@create_component(${name}.$$.fragment);`);
if (parent_nodes && this.renderer.options.hydratable) {
let nodes = parent_nodes;
if (has_css_custom_properties) {
nodes = block.get_unique_name(`${css_custom_properties_wrapper.name}_nodes`);
block.chunks.claim.push(b`
${css_custom_properties_wrapper} = @claim_element(${parent_nodes}, "DIV", { style: true })
var ${nodes} = @children(${css_custom_properties_wrapper});
`);
}
block.chunks.claim.push(
b`@claim_component(${name}.$$.fragment, ${parent_nodes});`
b`@claim_component(${name}.$$.fragment, ${nodes});`
);
}
block.chunks.mount.push(
b`@mount_component(${name}, ${parent_node || '#target'}, ${parent_node ? 'null' : '#anchor'});`
);
if (has_css_custom_properties) {
if (parent_node) {
block.chunks.mount.push(b`@append(${parent_node}, ${css_custom_properties_wrapper})`);
if (is_head(parent_node)) {
block.chunks.destroy.push(b`@detach(${css_custom_properties_wrapper});`);
}
} else {
block.chunks.mount.push(b`@insert(#target, ${css_custom_properties_wrapper}, #anchor);`);
// TODO we eventually need to consider what happens to elements
// that belong to the same outgroup as an outroing element...
block.chunks.destroy.push(b`if (detaching) @detach(${css_custom_properties_wrapper});`);
}
block.chunks.mount.push(b`@mount_component(${name}, ${css_custom_properties_wrapper}, null);`);
} else {
block.chunks.mount.push(
b`@mount_component(${name}, ${parent_node || '#target'}, ${parent_node ? 'null' : '#anchor'});`
);
}
block.chunks.intro.push(b`
@transition_in(${name}.$$.fragment, #local);

@ -1,4 +1,5 @@
import { string_literal } from '../../utils/stringify';
import { get_attribute_value } from './shared/get_attribute_value';
import Renderer, { RenderOptions } from '../Renderer';
import InlineComponent from '../../nodes/InlineComponent';
import { p, x } from 'code-red';
@ -86,5 +87,19 @@ export default function(node: InlineComponent, renderer: Renderer, options: Rend
${slot_fns}
}`;
if (node.css_custom_properties.length > 0) {
renderer.add_string('<div style="display: contents;');
node.css_custom_properties.forEach(attr => {
renderer.add_string(` ${attr.name}:`);
renderer.add_expression(get_attribute_value(attr));
renderer.add_string(';');
});
renderer.add_string('">');
}
renderer.add_expression(x`@validate_component(${expression}, "${node.name}").$$render($$result, ${props}, ${bindings}, ${slots})`);
if (node.css_custom_properties.length > 0) {
renderer.add_string('</div>');
}
}

@ -260,3 +260,11 @@ export function mkdirp(dir) {
// do nothing
}
}
export function prettyPrintPuppeteerAssertionError(message) {
const match = /Error: Expected "(.+)" to equal "(.+)"/.exec(message);
if (match) {
assert.equal(match[1], match[2]);
}
}

@ -0,0 +1,54 @@
export function deepEqual(a, b, message) {
if (!is_equal(a, b)) {
throw new Error(message || `Expected ${JSON.stringify(a)} to equal ${JSON.stringify(b)}`);
}
}
function is_equal(a, b) {
if (a && typeof a === 'object') {
const is_array = Array.isArray(a);
if (Array.isArray(b) !== is_array) return false;
if (is_array) {
if (a.length !== b.length) return false;
return a.every((value, i) => is_equal(value, b[i]));
}
const a_keys = Object.keys(a).sort();
const b_keys = Object.keys(b).sort();
if (a_keys.join(',') !== b_keys.join(',')) return false;
return a_keys.every(key => is_equal(a[key], b[key]));
}
return a === b;
}
export function equal(a, b, message) {
if (a != b) throw new Error(message || `Expected ${a} to equal ${b}`);
}
export function ok(condition, message) {
if (!condition) throw new Error(message || `Expected ${condition} to be truthy`);
}
export function htmlEqual(actual, expected, message) {
return deepEqual(
normalizeHtml(window, actual),
normalizeHtml(window, expected),
message
);
}
function normalizeHtml(window, html) {
try {
const node = window.document.createElement('div');
node.innerHTML = html
.replace(/<!--.*?-->/g, '')
.replace(/>[\s\r\n]+</g, '><')
.trim();
return node.innerHTML.replace(/<\/?noscript\/?>/g, '');
} catch (err) {
throw new Error(`Failed to normalize HTML:\n${html}`);
}
}

@ -0,0 +1,250 @@
import * as path from 'path';
import * as fs from 'fs';
import * as http from 'http';
import { rollup } from 'rollup';
import virtual from '@rollup/plugin-virtual';
import puppeteer from 'puppeteer';
import {
loadConfig,
loadSvelte,
mkdirp,
prettyPrintPuppeteerAssertionError
} from '../helpers';
import { deepEqual } from 'assert';
const page = `
<body>
<main></main>
<script src='/bundle.js'></script>
</body>
`;
let svelte;
let server;
let code;
let browser;
const internal = path.resolve('internal/index.mjs');
const index = path.resolve('index.mjs');
function create_server() {
return new Promise((fulfil, reject) => {
const server = http.createServer((req, res) => {
if (req.url === '/') {
res.end(page);
}
if (req.url === '/bundle.js') {
res.end(code);
}
});
server.on('error', reject);
server.listen('6789', () => {
fulfil(server);
});
});
}
const assert = fs.readFileSync(`${__dirname}/assert.js`, 'utf-8');
describe('runtime (puppeteer)', () => {
before(async () => {
svelte = loadSvelte(false);
console.log('[runtime-puppeteer] Loaded Svelte');
server = await create_server();
console.log('[runtime-puppeteer] Started server');
browser = await puppeteer.launch();
console.log('[runtime-puppeteer] Launched puppeteer browser');
});
after(async () => {
if (server) server.close();
if (browser) await browser.close();
});
const failed = new Set();
function runTest(dir, hydrate) {
if (dir[0] === '.') return;
const config = loadConfig(`${__dirname}/samples/${dir}/_config.js`);
const solo = config.solo || /\.solo/.test(dir);
const skip = config.skip || /\.skip/.test(dir);
if (hydrate && config.skip_if_hydrate) return;
if (solo && process.env.CI) {
throw new Error('Forgot to remove `solo: true` from test');
}
(skip ? it.skip : solo ? it.only : it)(`${dir} ${hydrate ? '(with hydration)' : ''}`, async () => {
if (failed.has(dir)) {
// this makes debugging easier, by only printing compiled output once
throw new Error('skipping test, already failed');
}
const warnings = [];
const bundle = await rollup({
input: 'main',
plugins: [
{
name: 'testing-runtime-puppeteer',
resolveId(importee) {
if (importee === 'svelte/internal' || importee === './internal') {
return internal;
}
if (importee === 'svelte') {
return index;
}
if (importee === 'main') {
return 'main';
}
},
load(id) {
if (id === 'main') {
return `
import SvelteComponent from ${JSON.stringify(path.join(__dirname, 'samples', dir, 'main.svelte'))};
import config from ${JSON.stringify(path.join(__dirname, 'samples', dir, '_config.js'))};
import * as assert from 'assert';
export default async function (target) {
let unhandled_rejection = false;
function unhandled_rejection_handler(event) {
unhandled_rejection = event.reason;
}
window.addEventListener('unhandledrejection', unhandled_rejection_handler);
try {
if (config.before_test) config.before_test();
const options = Object.assign({}, {
target,
hydrate: ${String(!!hydrate)},
props: config.props,
intro: config.intro
}, config.options || {});
const component = new SvelteComponent(options);
if (config.html) {
assert.htmlEqual(target.innerHTML, config.html);
}
if (config.test) {
await config.test({
assert,
component,
target,
window,
});
component.$destroy();
if (unhandled_rejection) {
throw unhandled_rejection;
}
} else {
component.$destroy();
assert.htmlEqual(target.innerHTML, '');
if (unhandled_rejection) {
throw unhandled_rejection;
}
}
if (config.after_test) config.after_test();
} catch (error) {
if (config.error) {
assert.equal(err.message, config.error);
} else {
throw error;
}
} finally {
window.removeEventListener('unhandledrejection', unhandled_rejection_handler);
}
}
`;
}
return null;
},
transform(code, id) {
if (id.endsWith('.svelte')) {
const compiled = svelte.compile(code.replace(/\r/g, ''), {
...config.compileOptions,
hydratable: hydrate,
immutable: config.immutable,
accessors: 'accessors' in config ? config.accessors : true
});
const out_dir = `${__dirname}/samples/${dir}/_output/${hydrate ? 'hydratable' : 'normal'}`;
const out = `${out_dir}/${path.basename(id).replace(/\.svelte$/, '.js')}`;
if (fs.existsSync(out)) {
fs.unlinkSync(out);
}
mkdirp(out_dir);
fs.writeFileSync(out, compiled.js.code, 'utf8');
compiled.warnings.forEach(w => warnings.push(w));
return compiled.js;
}
}
},
virtual({ assert })
]
});
const result = await bundle.generate({ format: 'iife', name: 'test' });
code = result.output[0].code;
const page = await browser.newPage();
page.on('console', (type) => {
console[type._type](type._text);
});
page.on('error', error => {
console.log('>>> an error happened');
console.error(error);
});
try {
await page.goto('http://localhost:6789');
const result = await page.evaluate(() => test(document.querySelector('main')));
if (result) console.log(result);
} catch (err) {
failed.add(dir);
prettyPrintPuppeteerAssertionError(err.message);
throw err;
} finally {
if (config.warnings) {
deepEqual(warnings.map(w => ({
code: w.code,
message: w.message,
pos: w.pos,
start: w.start,
end: w.end
})), config.warnings);
} else if (warnings.length) {
failed.add(dir);
/* eslint-disable no-unsafe-finally */
throw new Error('Received unexpected warnings');
}
}
});
}
fs.readdirSync(`${__dirname}/samples`).forEach(dir => {
runTest(dir, false);
runTest(dir, true);
});
});

@ -0,0 +1,17 @@
<script>
export let id;
</script>
<div {id}>
<p>Slider</p>
<span>Track</span>
</div>
<style>
p {
color: var(--rail-color);
}
span {
color: var(--track-color);
}
</style>

@ -0,0 +1,41 @@
export default {
props: {
railColor1: 'black',
trackColor1: 'red',
railColor2: 'green',
trackColor2: 'blue'
},
html: `
<div style="display: contents; --rail-color:black; --track-color:red;">
<div id="slider-1">
<p class="svelte-17ay6rc">Slider</p>
<span class="svelte-17ay6rc">Track</span>
</div>
</div>
<div style="display: contents; --rail-color:green; --track-color:blue;">
<div id="slider-2">
<p class="svelte-17ay6rc">Slider</p>
<span class="svelte-17ay6rc">Track</span>
</div>
</div>
`,
test({ component, assert, target }) {
component.railColor1 = 'yellow';
component.trackColor2 = 'orange';
assert.htmlEqual(target.innerHTML, `
<div style="display: contents; --rail-color:yellow; --track-color:red;">
<div id="slider-1">
<p class="svelte-17ay6rc">Slider</p>
<span class="svelte-17ay6rc">Track</span>
</div>
</div>
<div style="display: contents; --rail-color:green; --track-color:orange;">
<div id="slider-2">
<p class="svelte-17ay6rc">Slider</p>
<span class="svelte-17ay6rc">Track</span>
</div>
</div>
`);
}
};

@ -0,0 +1,23 @@
<script>
import Slider from './Slider.svelte';
export let railColor1;
export let railColor2;
export let trackColor1;
export let trackColor2;
function identity(color) {
return color;
}
</script>
<Slider
id="slider-1"
--rail-color={railColor1}
--track-color={trackColor1}
/>
<Slider
id="slider-2"
--rail-color={railColor2}
--track-color={identity(trackColor2)}
/>

@ -0,0 +1,17 @@
<script>
export let id;
</script>
<div {id}>
<p>Slider</p>
<span>Track</span>
</div>
<style>
p {
color: var(--rail-color);
}
span {
color: var(--track-color);
}
</style>

@ -0,0 +1,27 @@
export default {
html: `
<div style="display: contents; --rail-color:rgb(0, 0, 0); --track-color:rgb(255, 0, 0);">
<div id="slider-1">
<p class="svelte-17ay6rc">Slider</p>
<span class="svelte-17ay6rc">Track</span>
</div>
</div>
<div style="display: contents; --rail-color:rgb(0, 255, 0); --track-color:rgb(0, 0, 255);">
<div id="slider-2">
<p class="svelte-17ay6rc">Slider</p>
<span class="svelte-17ay6rc">Track</span>
</div>
</div>
`,
test({ target, window, assert }) {
const railColor1 = target.querySelector('#slider-1 p');
const trackColor1 = target.querySelector('#slider-1 span');
const railColor2 = target.querySelector('#slider-2 p');
const trackColor2 = target.querySelector('#slider-2 span');
assert.htmlEqual(window.getComputedStyle(railColor1).color, 'rgb(0, 0, 0)');
assert.htmlEqual(window.getComputedStyle(trackColor1).color, 'rgb(255, 0, 0)');
assert.htmlEqual(window.getComputedStyle(railColor2).color, 'rgb(0, 255, 0)');
assert.htmlEqual(window.getComputedStyle(trackColor2).color, 'rgb(0, 0, 255)');
}
};

@ -0,0 +1,15 @@
<script>
import Slider from './Slider.svelte';
</script>
<Slider
id="slider-1"
--rail-color="rgb(0, 0, 0)"
--track-color="rgb(255, 0, 0)"
/>
<Slider
id="slider-2"
--rail-color="rgb(0, 255, 0)"
--track-color="rgb(0, 0, 255)"
/>

@ -135,100 +135,105 @@ describe('ssr', () => {
});
// duplicate client-side tests, as far as possible
fs.readdirSync('test/runtime/samples').forEach(dir => {
if (dir[0] === '.') return;
runRuntimeSamples('runtime');
runRuntimeSamples('runtime-puppeteer');
const config = loadConfig(`./runtime/samples/${dir}/_config.js`);
const solo = config.solo || /\.solo/.test(dir);
function runRuntimeSamples(suite) {
fs.readdirSync(`test/${suite}/samples`).forEach(dir => {
if (dir[0] === '.') return;
if (solo && process.env.CI) {
throw new Error('Forgot to remove `solo: true` from test');
}
const config = loadConfig(`./${suite}/samples/${dir}/_config.js`);
const solo = config.solo || /\.solo/.test(dir);
if (config.skip_if_ssr) return;
if (solo && process.env.CI) {
throw new Error('Forgot to remove `solo: true` from test');
}
(config.skip ? it.skip : solo ? it.only : it)(dir, () => {
const cwd = path.resolve('test/runtime/samples', dir);
if (config.skip_if_ssr) return;
cleanRequireCache();
(config.skip ? it.skip : solo ? it.only : it)(dir, () => {
const cwd = path.resolve(`test/${suite}/samples`, dir);
delete global.window;
cleanRequireCache();
const compileOptions = {
sveltePath,
...config.compileOptions,
generate: 'ssr',
format: 'cjs'
};
require('../../register')(compileOptions);
delete global.window;
glob('**/*.svelte', { cwd }).forEach(file => {
if (file[0] === '_') return;
const compileOptions = {
sveltePath,
...config.compileOptions,
generate: 'ssr',
format: 'cjs'
};
const dir = `${cwd}/_output/ssr`;
const out = `${dir}/${file.replace(/\.svelte$/, '.js')}`;
require('../../register')(compileOptions);
if (fs.existsSync(out)) {
fs.unlinkSync(out);
}
glob('**/*.svelte', { cwd }).forEach(file => {
if (file[0] === '_') return;
mkdirp(dir);
const dir = `${cwd}/_output/ssr`;
const out = `${dir}/${file.replace(/\.svelte$/, '.js')}`;
try {
const { js } = compile(
fs.readFileSync(`${cwd}/${file}`, 'utf-8'),
{
...compileOptions,
filename: file
}
);
if (fs.existsSync(out)) {
fs.unlinkSync(out);
}
fs.writeFileSync(out, js.code);
} catch (err) {
// do nothing
}
});
mkdirp(dir);
try {
if (config.before_test) config.before_test();
try {
const { js } = compile(
fs.readFileSync(`${cwd}/${file}`, 'utf-8'),
{
...compileOptions,
filename: file
}
);
const Component = require(`../runtime/samples/${dir}/main.svelte`).default;
const { html } = Component.render(config.props, {
store: (config.store !== true) && config.store
fs.writeFileSync(out, js.code);
} catch (err) {
// do nothing
}
});
if (config.ssrHtml) {
assert.htmlEqual(html, config.ssrHtml);
} else if (config.html) {
assert.htmlEqual(html, config.html);
}
try {
if (config.before_test) config.before_test();
if (config.test_ssr) {
config.test_ssr({ assert });
}
const Component = require(`../${suite}/samples/${dir}/main.svelte`).default;
const { html } = Component.render(config.props, {
store: (config.store !== true) && config.store
});
if (config.after_test) config.after_test();
if (config.ssrHtml) {
assert.htmlEqual(html, config.ssrHtml);
} else if (config.html) {
assert.htmlEqual(html, config.html);
}
if (config.show) {
showOutput(cwd, compileOptions);
}
} catch (err) {
err.stack += `\n\ncmd-click: ${path.relative(process.cwd(), cwd)}/main.svelte`;
if (config.test_ssr) {
config.test_ssr({ assert });
}
if (config.after_test) config.after_test();
if (config.error) {
if (typeof config.error === 'function') {
config.error(assert, err);
if (config.show) {
showOutput(cwd, compileOptions);
}
} catch (err) {
err.stack += `\n\ncmd-click: ${path.relative(process.cwd(), cwd)}/main.svelte`;
if (config.error) {
if (typeof config.error === 'function') {
config.error(assert, err);
} else {
assert.equal(err.message, config.error);
}
} else {
assert.equal(err.message, config.error);
showOutput(cwd, compileOptions);
throw err;
}
} else {
showOutput(cwd, compileOptions);
throw err;
} finally {
set_current_component(null);
}
} finally {
set_current_component(null);
}
});
});
});
}
});

Loading…
Cancel
Save