-
-
Notifications
You must be signed in to change notification settings - Fork 4.6k
feat: use state proxy ancestry for ownership validation #11184
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
Changes from 8 commits
4c16847
14b0be3
8bfef74
7a5918a
fab34ab
8e515b1
5440862
a638a07
f6b5553
ed78fed
e1c79ba
abeb31a
084aabf
d5791fb
47e35cf
60b24de
32183d1
05856f3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'svelte': patch | ||
--- | ||
|
||
feat: use state proxy ancestry for ownership validation |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,8 @@ | ||
/** @typedef {{ file: string, line: number, column: number }} Location */ | ||
|
||
import { STATE_SYMBOL } from '../constants.js'; | ||
import { untrack } from '../runtime.js'; | ||
import { render_effect } from '../reactivity/effects.js'; | ||
import { current_component_context, untrack } from '../runtime.js'; | ||
|
||
/** @type {Record<string, Array<{ start: Location, end: Location, component: Function }>>} */ | ||
const boundaries = {}; | ||
|
@@ -63,6 +64,8 @@ function get_component() { | |
return null; | ||
} | ||
|
||
export const ADD_OWNER = Symbol('ADD_OWNER'); | ||
|
||
/** | ||
* Together with `mark_module_end`, this function establishes the boundaries of a `.svelte` file, | ||
* such that subsequent calls to `get_component` can tell us which component is responsible | ||
|
@@ -98,60 +101,87 @@ export function mark_module_end(component) { | |
} | ||
|
||
/** | ||
* | ||
* @param {any} object | ||
* @param {any} owner | ||
* @param {boolean} [global] | ||
*/ | ||
export function add_owner(object, owner) { | ||
untrack(() => { | ||
add_owner_to_object(object, owner); | ||
}); | ||
export function add_owner(object, owner, global = false) { | ||
if (object && !global) { | ||
// @ts-expect-error | ||
const component = current_component_context.function; | ||
const metadata = object[STATE_SYMBOL]; | ||
if (metadata && !has_owner(metadata, component)) { | ||
let original = get_owner(metadata); | ||
|
||
if (owner.filename !== component.filename) { | ||
let message = `${component.filename} passed a value to ${owner.filename} with \`bind:\`, but the value is owned by ${original.filename}. Consider creating a binding between ${original.filename} and ${component.filename}`; | ||
|
||
// eslint-disable-next-line no-console | ||
console.warn(message); | ||
} | ||
} | ||
} | ||
|
||
add_owner_to_object(object, owner); | ||
} | ||
|
||
/** | ||
* @param {any} object | ||
* @param {Function} owner | ||
*/ | ||
function add_owner_to_object(object, owner) { | ||
if (object?.[STATE_SYMBOL]?.o && !object[STATE_SYMBOL].o.has(owner)) { | ||
object[STATE_SYMBOL].o.add(owner); | ||
|
||
for (const key in object) { | ||
add_owner_to_object(object[key], owner); | ||
const metadata = /** @type {import('#client').ProxyMetadata} */ (object?.[STATE_SYMBOL]); | ||
|
||
if (metadata) { | ||
// this is a state proxy, add owner directly | ||
(metadata.owners ??= new Set()).add(owner); | ||
} else if (object && typeof object === 'object') { | ||
if (object[ADD_OWNER]) { | ||
// this is a class with state fields | ||
render_effect(() => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why is a render effect necessary here? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. added a comment |
||
object[ADD_OWNER](owner); | ||
}); | ||
} else { | ||
// recurse until we find a state proxy | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should we bail on non-POJOs here? IIUC this would also recurse into DOM elements, for example There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah yeah, probably a good idea |
||
for (const key in object) { | ||
add_owner_to_object(object[key], owner); | ||
} | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* @param {any} object | ||
* @param {import('#client').ProxyMetadata} metadata | ||
* @param {Function} component | ||
* @returns {boolean} | ||
*/ | ||
export function strip_owner(object) { | ||
untrack(() => { | ||
strip_owner_from_object(object); | ||
}); | ||
function has_owner(metadata, component) { | ||
if (metadata.owners === null) { | ||
return metadata.parent === null ? true : has_owner(metadata.parent, component); | ||
} | ||
|
||
return metadata.owners.has(component); | ||
} | ||
|
||
/** | ||
* @param {any} object | ||
* @param {import('#client').ProxyMetadata} metadata | ||
* @returns {any} | ||
*/ | ||
function strip_owner_from_object(object) { | ||
if (object?.[STATE_SYMBOL]?.o) { | ||
object[STATE_SYMBOL].o = null; | ||
|
||
for (const key in object) { | ||
strip_owner(object[key]); | ||
} | ||
} | ||
function get_owner(metadata) { | ||
return ( | ||
metadata?.owners?.values().next().value ?? | ||
get_owner(/** @type {import('#client').ProxyMetadata} */ (metadata.parent)) | ||
); | ||
} | ||
|
||
/** | ||
* @param {Set<Function>} owners | ||
* @param {import('#client').ProxyMetadata} metadata | ||
*/ | ||
export function check_ownership(owners) { | ||
export function check_ownership(metadata) { | ||
const component = get_component(); | ||
|
||
if (component && !owners.has(component)) { | ||
let original = [...owners][0]; | ||
if (component && !has_owner(metadata, component)) { | ||
let original = get_owner(metadata); | ||
|
||
let message = | ||
// @ts-expect-error | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
<script> | ||
import { getContext } from 'svelte'; | ||
|
||
const foo = getContext('foo') | ||
</script> | ||
|
||
<button onclick={() => { | ||
foo.person.name.last = 'T'; | ||
}}>mutate</button> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import { tick } from 'svelte'; | ||
import { test } from '../../test'; | ||
|
||
/** @type {typeof console.warn} */ | ||
let warn; | ||
|
||
/** @type {any[]} */ | ||
let warnings = []; | ||
|
||
export default test({ | ||
compileOptions: { | ||
dev: true | ||
}, | ||
|
||
before_test: () => { | ||
warn = console.warn; | ||
|
||
console.warn = (...args) => { | ||
warnings.push(...args); | ||
}; | ||
}, | ||
|
||
after_test: () => { | ||
console.warn = warn; | ||
warnings = []; | ||
}, | ||
|
||
async test({ assert, target }) { | ||
const btn = target.querySelector('button'); | ||
|
||
await btn?.click(); | ||
await tick(); | ||
assert.deepEqual(warnings.length, 0); | ||
} | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
<script> | ||
import { setContext } from 'svelte'; | ||
import Child from './Child.svelte'; | ||
|
||
class Person { | ||
name = $state({ | ||
first: 'Mr', | ||
last: 'Bean' | ||
}); | ||
} | ||
|
||
setContext('foo', { | ||
person: new Person() | ||
}); | ||
</script> | ||
|
||
<Child /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pretty sure this is wrong, because
null
means "everything's allowed", causing unwanted narrowing in a case like this. Looking into it.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pushed a fix