diff --git a/CHANGELOG.md b/CHANGELOG.md
index f1564848b5..7848af35be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Svelte changelog
+## Unreleased
+
+* Expose object of unknown props in `$$restProps` ([#2930](https://github.com/sveltejs/svelte/issues/2930))
+
## 3.19.2
* In `dev` mode, display a runtime warning when a component is passed an unexpected slot ([#1020](https://github.com/sveltejs/svelte/issues/1020), [#1447](https://github.com/sveltejs/svelte/issues/1447))
diff --git a/site/content/docs/02-template-syntax.md b/site/content/docs/02-template-syntax.md
index d25b348254..ea36474bed 100644
--- a/site/content/docs/02-template-syntax.md
+++ b/site/content/docs/02-template-syntax.md
@@ -113,6 +113,15 @@ An element or component can have multiple spread attributes, interspersed with r
```
+---
+
+*`$$restProps`* contains only the props which are *not* declared with `export`. It can be used to pass down other unknown attributes to an element in a component.
+
+```html
+
+```
+
+---
### Text expressions
diff --git a/src/compiler/compile/Component.ts b/src/compiler/compile/Component.ts
index a7fe2992d1..ae2c4b704a 100644
--- a/src/compiler/compile/Component.ts
+++ b/src/compiler/compile/Component.ts
@@ -28,6 +28,7 @@ import { Node, ImportDeclaration, Identifier, Program, ExpressionStatement, Assi
import add_to_set from './utils/add_to_set';
import check_graph_for_cycles from './utils/check_graph_for_cycles';
import { print, x, b } from 'code-red';
+import { is_reserved_keyword } from './utils/reserved_keywords';
interface ComponentOptions {
namespace?: string;
@@ -185,7 +186,7 @@ export default class Component {
if (variable) {
variable.referenced = true;
- } else if (name === '$$props') {
+ } else if (is_reserved_keyword(name)) {
this.add_var({
name,
injected: true,
@@ -649,7 +650,7 @@ export default class Component {
reassigned: true,
initialised: true,
});
- } else if (name === '$$props') {
+ } else if (is_reserved_keyword(name)) {
this.add_var({
name,
injected: true,
@@ -1276,7 +1277,7 @@ export default class Component {
warn_if_undefined(name: string, node, template_scope: TemplateScope) {
if (name[0] === '$') {
- if (name === '$' || name[1] === '$' && name !== '$$props') {
+ if (name === '$' || name[1] === '$' && !is_reserved_keyword(name)) {
this.error(node, {
code: 'illegal-global',
message: `${name} is an illegal variable name`
@@ -1285,7 +1286,7 @@ export default class Component {
this.has_reactive_assignments = true; // TODO does this belong here?
- if (name === '$$props') return;
+ if (is_reserved_keyword(name)) return;
name = name.slice(1);
}
diff --git a/src/compiler/compile/nodes/shared/Expression.ts b/src/compiler/compile/nodes/shared/Expression.ts
index db0908c3f4..2f2541c720 100644
--- a/src/compiler/compile/nodes/shared/Expression.ts
+++ b/src/compiler/compile/nodes/shared/Expression.ts
@@ -13,6 +13,7 @@ import { b } from 'code-red';
import { invalidate } from '../../render_dom/invalidate';
import { Node, FunctionExpression, Identifier } from 'estree';
import { TemplateNode } from '../../../interfaces';
+import { is_reserved_keyword } from '../../utils/reserved_keywords';
type Owner = Wrapper | TemplateNode;
@@ -158,7 +159,7 @@ export default class Expression {
dynamic_dependencies() {
return Array.from(this.dependencies).filter(name => {
if (this.template_scope.is_let(name)) return true;
- if (name === '$$props') return true;
+ if (is_reserved_keyword(name)) return true;
const variable = this.component.var_lookup.get(name);
return is_dynamic(variable);
@@ -355,7 +356,7 @@ function get_function_name(_node, parent) {
}
function is_contextual(component: Component, scope: TemplateScope, name: string) {
- if (name === '$$props') return true;
+ if (is_reserved_keyword(name)) return true;
// if it's a name below root scope, it's contextual
if (!scope.is_top_level(name)) return true;
diff --git a/src/compiler/compile/render_dom/Renderer.ts b/src/compiler/compile/render_dom/Renderer.ts
index 2501279214..e933faf97a 100644
--- a/src/compiler/compile/render_dom/Renderer.ts
+++ b/src/compiler/compile/render_dom/Renderer.ts
@@ -5,6 +5,7 @@ import FragmentWrapper from './wrappers/Fragment';
import { x } from 'code-red';
import { Node, Identifier, MemberExpression, Literal, Expression, BinaryExpression } from 'estree';
import flatten_reference from '../utils/flatten_reference';
+import { reserved_keywords } from '../utils/reserved_keywords';
interface ContextMember {
name: string;
@@ -50,9 +51,11 @@ export default class Renderer {
// ensure store values are included in context
component.vars.filter(v => v.subscribable).forEach(v => this.add_to_context(`$${v.name}`));
- if (component.var_lookup.has('$$props')) {
- this.add_to_context('$$props');
- }
+ reserved_keywords.forEach(keyword => {
+ if (component.var_lookup.has(keyword)) {
+ this.add_to_context(keyword);
+ }
+ });
if (component.slots.size > 0) {
this.add_to_context('$$scope');
diff --git a/src/compiler/compile/render_dom/index.ts b/src/compiler/compile/render_dom/index.ts
index fc3c94a2be..4009c6bddf 100644
--- a/src/compiler/compile/render_dom/index.ts
+++ b/src/compiler/compile/render_dom/index.ts
@@ -71,14 +71,24 @@ export default function dom(
}
const uses_props = component.var_lookup.has('$$props');
- const $$props = uses_props ? `$$new_props` : `$$props`;
+ const uses_rest = component.var_lookup.has('$$restProps');
+ const $$props = uses_props || uses_rest ? `$$new_props` : `$$props`;
const props = component.vars.filter(variable => !variable.module && variable.export_name);
const writable_props = props.filter(variable => variable.writable);
- const set = (uses_props || writable_props.length > 0 || component.slots.size > 0)
+ const omit_props_names = component.get_unique_name('omit_props_names');
+ const compute_rest = x`@compute_rest_props($$props, ${omit_props_names.name})`;
+ const rest = uses_rest ? b`
+ const ${omit_props_names.name} = [${props.map(prop => `"${prop.export_name}"`).join(',')}];
+ let $$restProps = ${compute_rest};
+ ` : null;
+
+ const set = (uses_props || uses_rest || writable_props.length > 0 || component.slots.size > 0)
? x`
${$$props} => {
${uses_props && renderer.invalidate('$$props', x`$$props = @assign(@assign({}, $$props), @exclude_internal_props($$new_props))`)}
+ ${uses_rest && !uses_props && x`$$props = @assign(@assign({}, $$props), @exclude_internal_props($$new_props))`}
+ ${uses_rest && renderer.invalidate('$$restProps', x`$$restProps = ${compute_rest}`)}
${writable_props.map(prop =>
b`if ('${prop.export_name}' in ${$$props}) ${renderer.invalidate(prop.name, x`${prop.name} = ${$$props}.${prop.export_name}`)};`
)}
@@ -341,20 +351,20 @@ export default function dom(
component.reactive_declarations.forEach(d => {
const dependencies = Array.from(d.dependencies);
- const uses_props = !!dependencies.find(n => n === '$$props');
+ const uses_rest_or_props = !!dependencies.find(n => n === '$$props' || n === '$$restProps');
const writable = dependencies.filter(n => {
const variable = component.var_lookup.get(n);
return variable && (variable.export_name || variable.mutated || variable.reassigned);
});
- const condition = !uses_props && writable.length > 0 && renderer.dirty(writable, true);
+ const condition = !uses_rest_or_props && writable.length > 0 && renderer.dirty(writable, true);
let statement = d.node; // TODO remove label (use d.node.body) if it's not referenced
if (condition) statement = b`if (${condition}) { ${statement} }`[0] as Statement;
- if (condition || uses_props) {
+ if (condition || uses_rest_or_props) {
reactive_declarations.push(statement);
} else {
fixed_reactive_declarations.push(statement);
@@ -383,7 +393,7 @@ export default function dom(
});
let unknown_props_check;
- if (component.compile_options.dev && !component.var_lookup.has('$$props')) {
+ if (component.compile_options.dev && !(uses_props || uses_rest)) {
unknown_props_check = b`
const writable_props = [${writable_props.map(prop => x`'${prop.export_name}'`)}];
@_Object.keys($$props).forEach(key => {
@@ -402,6 +412,8 @@ export default function dom(
body.push(b`
function ${definition}(${args}) {
+ ${rest}
+
${reactive_store_declarations}
${reactive_store_subscriptions}
@@ -473,7 +485,7 @@ export default function dom(
@insert(options.target, this, options.anchor);
}
- ${(props.length > 0 || uses_props) && b`
+ ${(props.length > 0 || uses_props || uses_rest) && b`
if (options.props) {
this.$set(options.props);
@flush();
diff --git a/src/compiler/compile/render_ssr/index.ts b/src/compiler/compile/render_ssr/index.ts
index ad22869d32..c87fe3bdd9 100644
--- a/src/compiler/compile/render_ssr/index.ts
+++ b/src/compiler/compile/render_ssr/index.ts
@@ -31,6 +31,10 @@ export default function ssr(
{ code: null, map: null } :
component.stylesheet.render(options.filename, true);
+ const uses_rest = component.var_lookup.has('$$restProps');
+ const props = component.vars.filter(variable => !variable.module && variable.export_name);
+ const rest = uses_rest ? b`let $$restProps = @compute_rest_props($$props, [${props.map(prop => `"${prop.export_name}"`).join(',')}]);` : null;
+
const reactive_stores = component.vars.filter(variable => variable.name[0] === '$' && variable.name[1] !== '$');
const reactive_store_values = reactive_stores
.map(({ name }) => {
@@ -130,6 +134,7 @@ export default function ssr(
return ${literal};`;
const blocks = [
+ rest,
...reactive_stores.map(({ name }) => {
const store_name = name.slice(1);
const store = component.var_lookup.get(store_name);
diff --git a/src/compiler/compile/utils/reserved_keywords.ts b/src/compiler/compile/utils/reserved_keywords.ts
new file mode 100644
index 0000000000..75825c1719
--- /dev/null
+++ b/src/compiler/compile/utils/reserved_keywords.ts
@@ -0,0 +1,5 @@
+export const reserved_keywords = new Set(["$$props", "$$restProps"]);
+
+export function is_reserved_keyword(name) {
+ return reserved_keywords.has(name);
+}
\ No newline at end of file
diff --git a/src/runtime/internal/utils.ts b/src/runtime/internal/utils.ts
index acb88c7669..487116b655 100644
--- a/src/runtime/internal/utils.ts
+++ b/src/runtime/internal/utils.ts
@@ -109,6 +109,13 @@ export function exclude_internal_props(props) {
return result;
}
+export function compute_rest_props(props, keys) {
+ const rest = {};
+ keys = new Set(keys);
+ for (const k in props) if (!keys.has(k) && k[0] !== '$') rest[k] = props[k];
+ return rest;
+}
+
export function once(fn) {
let ran = false;
return function(this: any, ...args) {
diff --git a/test/runtime/samples/$$rest-without-props/App.svelte b/test/runtime/samples/$$rest-without-props/App.svelte
new file mode 100644
index 0000000000..e98fec30d4
--- /dev/null
+++ b/test/runtime/samples/$$rest-without-props/App.svelte
@@ -0,0 +1,12 @@
+
+
Length: {length}
+Values: {values.join(',')}
+
+
\ No newline at end of file
diff --git a/test/runtime/samples/$$rest-without-props/_config.js b/test/runtime/samples/$$rest-without-props/_config.js
new file mode 100644
index 0000000000..017f9df561
--- /dev/null
+++ b/test/runtime/samples/$$rest-without-props/_config.js
@@ -0,0 +1,54 @@
+export default {
+ props: {
+ a: 3,
+ b: 4,
+ c: 5,
+ d: 6
+ },
+ html: `
+ Length: 3
+ Values: 4,5,1
+
+
+ `,
+ async test({ assert, target, window, }) {
+ const [btn1, btn2, btn3, btn4] = target.querySelectorAll('button');
+ const clickEvent = new window.MouseEvent('click');
+
+ await btn1.dispatchEvent(clickEvent);
+
+ assert.htmlEqual(target.innerHTML, `
+ Length: 3
+ Values: 4,5,1
+
+
+ `);
+
+ await btn2.dispatchEvent(clickEvent);
+
+ assert.htmlEqual(target.innerHTML, `
+ Length: 3
+ Values: 34,5,1
+
+
+ `);
+
+ await btn3.dispatchEvent(clickEvent);
+
+ assert.htmlEqual(target.innerHTML, `
+ Length: 3
+ Values: 34,5,31
+
+
+ `);
+
+ await btn4.dispatchEvent(clickEvent);
+
+ assert.htmlEqual(target.innerHTML, `
+ Length: 4
+ Values: 34,5,31,2
+
+
+ `);
+ }
+};
diff --git a/test/runtime/samples/$$rest-without-props/main.svelte b/test/runtime/samples/$$rest-without-props/main.svelte
new file mode 100644
index 0000000000..21b2690584
--- /dev/null
+++ b/test/runtime/samples/$$rest-without-props/main.svelte
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/runtime/samples/$$rest/App.svelte b/test/runtime/samples/$$rest/App.svelte
new file mode 100644
index 0000000000..875372f670
--- /dev/null
+++ b/test/runtime/samples/$$rest/App.svelte
@@ -0,0 +1,13 @@
+
+Length: {length}
+Values: {values.join(',')}
+
+
+
\ No newline at end of file
diff --git a/test/runtime/samples/$$rest/_config.js b/test/runtime/samples/$$rest/_config.js
new file mode 100644
index 0000000000..255927f354
--- /dev/null
+++ b/test/runtime/samples/$$rest/_config.js
@@ -0,0 +1,60 @@
+export default {
+ props: {
+ a: 3,
+ b: 4,
+ c: 5,
+ d: 6
+ },
+ html: `
+ Length: 3
+ Values: 4,5,1
+
+
+
+ `,
+
+ async test({ assert, target, window, }) {
+ const [btn1, btn2, btn3, btn4] = target.querySelectorAll('button');
+ const clickEvent = new window.MouseEvent('click');
+
+ await btn1.dispatchEvent(clickEvent);
+
+ assert.htmlEqual(target.innerHTML, `
+ Length: 3
+ Values: 4,5,1
+
+
+
+ `);
+
+ await btn2.dispatchEvent(clickEvent);
+
+ assert.htmlEqual(target.innerHTML, `
+ Length: 3
+ Values: 34,5,1
+
+
+
+ `);
+
+ await btn3.dispatchEvent(clickEvent);
+
+ assert.htmlEqual(target.innerHTML, `
+ Length: 3
+ Values: 34,5,31
+
+
+
+ `);
+
+ await btn4.dispatchEvent(clickEvent);
+
+ assert.htmlEqual(target.innerHTML, `
+ Length: 4
+ Values: 34,5,31,2
+
+
+
+ `);
+ }
+};
diff --git a/test/runtime/samples/$$rest/main.svelte b/test/runtime/samples/$$rest/main.svelte
new file mode 100644
index 0000000000..21b2690584
--- /dev/null
+++ b/test/runtime/samples/$$rest/main.svelte
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/runtime/samples/dev-warning-unknown-props-with-$$props/Foo.svelte b/test/runtime/samples/dev-warning-unknown-props-with-$$props/Foo.svelte
index 9e5c62339d..375b1a6a0a 100644
--- a/test/runtime/samples/dev-warning-unknown-props-with-$$props/Foo.svelte
+++ b/test/runtime/samples/dev-warning-unknown-props-with-$$props/Foo.svelte
@@ -4,4 +4,4 @@
{foo}
-{JSON.stringify($$props)}
+{JSON.stringify($$restProps)}
diff --git a/test/runtime/samples/dev-warning-unknown-props-with-$$rest/Foo.svelte b/test/runtime/samples/dev-warning-unknown-props-with-$$rest/Foo.svelte
new file mode 100644
index 0000000000..9e5c62339d
--- /dev/null
+++ b/test/runtime/samples/dev-warning-unknown-props-with-$$rest/Foo.svelte
@@ -0,0 +1,7 @@
+
+
+{foo}
+{JSON.stringify($$props)}
diff --git a/test/runtime/samples/dev-warning-unknown-props-with-$$rest/_config.js b/test/runtime/samples/dev-warning-unknown-props-with-$$rest/_config.js
new file mode 100644
index 0000000000..62ad08624d
--- /dev/null
+++ b/test/runtime/samples/dev-warning-unknown-props-with-$$rest/_config.js
@@ -0,0 +1,7 @@
+export default {
+ compileOptions: {
+ dev: true
+ },
+
+ warnings: []
+};
diff --git a/test/runtime/samples/dev-warning-unknown-props-with-$$rest/main.svelte b/test/runtime/samples/dev-warning-unknown-props-with-$$rest/main.svelte
new file mode 100644
index 0000000000..1566cf3e41
--- /dev/null
+++ b/test/runtime/samples/dev-warning-unknown-props-with-$$rest/main.svelte
@@ -0,0 +1,5 @@
+
+
+