DRY out snapshot code

better-snapshot
Rich Harris 1 year ago
parent 307f15d5f7
commit 56ecdbaeac

@ -1,7 +1,6 @@
import { snapshot } from '../proxy.js'; import { snapshot } from '../reactivity/snapshot.js';
import { render_effect } from '../reactivity/effects.js'; import { render_effect } from '../reactivity/effects.js';
import { current_effect, deep_read } from '../runtime.js'; import { current_effect, deep_read } from '../runtime.js';
import { array_prototype, get_prototype_of, object_prototype } from '../utils.js';
/** @type {Function | null} */ /** @type {Function | null} */
export let inspect_fn = null; export let inspect_fn = null;
@ -32,7 +31,7 @@ export function inspect(get_value, inspector = console.log) {
// calling `inspector` directly inside the effect, so that // calling `inspector` directly inside the effect, so that
// we get useful stack traces // we get useful stack traces
var fn = () => { var fn = () => {
const value = deep_snapshot(get_value()); const value = snapshot(get_value());
inspector(initial ? 'init' : 'update', ...value); inspector(initial ? 'init' : 'update', ...value);
}; };
@ -56,43 +55,3 @@ export function inspect(get_value, inspector = console.log) {
}; };
}); });
} }
/**
* Like `snapshot`, but recursively traverses into normal arrays/objects to find potential states in them.
* @param {any} value
* @param {Map<any, any>} visited
* @returns {any}
*/
function deep_snapshot(value, visited = new Map()) {
if (typeof value === 'object' && value !== null && !visited.has(value)) {
const unstated = snapshot(value);
if (unstated !== value) {
visited.set(value, unstated);
return unstated;
}
const prototype = get_prototype_of(value);
// Only deeply snapshot plain objects and arrays
if (prototype === object_prototype || prototype === array_prototype) {
let contains_unstated = false;
/** @type {any} */
const nested_unstated = Array.isArray(value) ? [] : {};
for (let key in value) {
const result = deep_snapshot(value[key], visited);
nested_unstated[key] = result;
if (result !== value[key]) {
contains_unstated = true;
}
}
visited.set(value, contains_unstated ? nested_unstated : value);
} else {
visited.set(value, value);
}
}
return visited.get(value) ?? value;
}

@ -88,6 +88,7 @@ export {
update_pre_prop, update_pre_prop,
update_prop update_prop
} from './reactivity/props.js'; } from './reactivity/props.js';
export { snapshot } from './reactivity/snapshot.js';
export { export {
invalidate_store, invalidate_store,
mutate_store, mutate_store,
@ -128,7 +129,7 @@ export {
validate_store validate_store
} from './validate.js'; } from './validate.js';
export { raf } from './timing.js'; export { raf } from './timing.js';
export { proxy, snapshot } from './proxy.js'; export { proxy } from './proxy.js';
export { create_custom_element } from './dom/elements/custom-element.js'; export { create_custom_element } from './dom/elements/custom-element.js';
export { export {
child, child,

@ -10,7 +10,6 @@ import {
array_prototype, array_prototype,
define_property, define_property,
get_descriptor, get_descriptor,
get_descriptors,
get_prototype_of, get_prototype_of,
is_array, is_array,
is_frozen, is_frozen,
@ -87,63 +86,6 @@ export function proxy(value, immutable = true, parent = null) {
return value; return value;
} }
/**
* @template {import('#client').ProxyStateObject} T
* @param {T} value
* @param {Map<T, Record<string | symbol, any>>} already_unwrapped
* @returns {Record<string | symbol, any>}
*/
function unwrap(value, already_unwrapped) {
if (typeof value === 'object' && value != null && STATE_SYMBOL in value) {
const unwrapped = already_unwrapped.get(value);
if (unwrapped !== undefined) {
return unwrapped;
}
if (is_array(value)) {
/** @type {Record<string | symbol, any>} */
const array = [];
already_unwrapped.set(value, array);
for (const element of value) {
array.push(unwrap(element, already_unwrapped));
}
return array;
} else {
/** @type {Record<string | symbol, any>} */
const obj = {};
const keys = Reflect.ownKeys(value);
const descriptors = get_descriptors(value);
already_unwrapped.set(value, obj);
for (const key of keys) {
if (key === STATE_SYMBOL) continue;
if (descriptors[key].get) {
define_property(obj, key, descriptors[key]);
} else {
/** @type {T} */
const property = value[key];
obj[key] = unwrap(property, already_unwrapped);
}
}
return obj;
}
}
return value;
}
/**
* @template T
* @param {T} value
* @returns {T}
*/
export function snapshot(value) {
return /** @type {T} */ (
unwrap(/** @type {import('#client').ProxyStateObject} */ (value), new Map())
);
}
/** /**
* @param {import('#client').Source<number>} signal * @param {import('#client').Source<number>} signal
* @param {1 | -1} [d] * @param {1 | -1} [d]

@ -0,0 +1,46 @@
import { STATE_SYMBOL } from '../constants.js';
import { is_array } from '../utils.js';
/**
* @template {any} T
* @param {T} value
* @param {Map<any, any>} values
* @returns {T}
*/
export function snapshot(value, values = new Map()) {
if (typeof value !== 'object' || value === null) {
return value;
}
if (STATE_SYMBOL in value) {
var unwrapped = /** @type {T} */ (values.get(value));
if (unwrapped !== undefined) {
return unwrapped;
}
if (is_array(value)) {
var length = value.length;
var array = Array(length);
values.set(value, array);
for (var i = 0; i < length; i += 1) {
array[i] = snapshot(value[i], values);
}
return /** @type {T} */ (array);
}
/** @type {Record<string | symbol, any>} */
var obj = {};
values.set(value, obj);
for (var [k, v] of Object.entries(value)) {
obj[k] = snapshot(v, values);
}
return /** @type {T} */ (obj);
}
return value;
}

@ -1,6 +1,6 @@
import { DEV } from 'esm-env'; import { DEV } from 'esm-env';
import { get_descriptors, get_prototype_of, is_frozen, object_freeze } from './utils.js'; import { get_descriptors, get_prototype_of, is_frozen, object_freeze } from './utils.js';
import { snapshot } from './proxy.js'; import { snapshot } from './reactivity/snapshot.js';
import { destroy_effect, effect, execute_effect_teardown } from './reactivity/effects.js'; import { destroy_effect, effect, execute_effect_teardown } from './reactivity/effects.js';
import { import {
EFFECT, EFFECT,

@ -25,13 +25,6 @@ export default test({
console.log = original_log; console.log = original_log;
}, },
async test({ assert, target }) { async test({ assert, target }) {
const button = target.querySelector('button');
flushSync(() => {
button?.click();
});
assert.htmlEqual(target.innerHTML, `<button>update</button>\n1`);
assert.deepEqual(log, [ assert.deepEqual(log, [
'init', 'init',
{ {
@ -40,7 +33,19 @@ export default test({
list: [] list: []
}, },
derived: [] derived: []
}, }
]);
log.length = 0;
const button = target.querySelector('button');
flushSync(() => {
button?.click();
});
assert.htmlEqual(target.innerHTML, `<button>update</button>\n1`);
assert.deepEqual(log, [
'update', 'update',
{ {
data: { data: {

@ -1,40 +1,34 @@
import { test } from '../../test'; import { test } from '../../test';
/** /** @type {any[]} */
* @type {any[]}
*/
let log; let log;
/**
* @type {typeof console.log}} let original_log = console.log;
*/
let original_log;
export default test({ export default test({
compileOptions: { compileOptions: {
dev: true dev: true
}, },
before_test() { before_test() {
log = []; log = [];
original_log = console.log;
console.log = (...v) => { console.log = (...v) => {
log.push(...v); log.push(...v);
}; };
}, },
after_test() { after_test() {
console.log = original_log; console.log = original_log;
}, },
async test({ assert, target }) { async test({ assert, target }) {
assert.deepEqual(log, ['init', { x: { count: 0 } }, [{ count: 0 }]]);
log.length = 0;
const [b1] = target.querySelectorAll('button'); const [b1] = target.querySelectorAll('button');
b1.click(); b1.click();
await Promise.resolve(); await Promise.resolve();
assert.deepEqual(log, [ assert.deepEqual(log, ['update', { x: { count: 1 } }, [{ count: 1 }]]);
'init',
{ x: { count: 0 } },
[{ count: 0 }],
'update',
{ x: { count: 1 } },
[{ count: 1 }]
]);
} }
}); });

@ -1,5 +1,5 @@
<script> <script>
let x = $state({count: 0}); let x = $state({ count: 0 });
$inspect({x}, [x]); $inspect({x}, [x]);
</script> </script>

@ -1,33 +1,34 @@
import { test } from '../../test'; import { test } from '../../test';
/** /** @type {any[]} */
* @type {any[]}
*/
let log; let log;
/**
* @type {typeof console.log}} let original_log = console.log;
*/
let original_log;
export default test({ export default test({
compileOptions: { compileOptions: {
dev: true dev: true
}, },
before_test() { before_test() {
log = []; log = [];
original_log = console.log;
console.log = (...v) => { console.log = (...v) => {
log.push(...v); log.push(...v);
}; };
}, },
after_test() { after_test() {
console.log = original_log; console.log = original_log;
}, },
async test({ assert, target }) { async test({ assert, target }) {
assert.deepEqual(log, ['init', {}, 'init', []]);
log.length = 0;
const [btn] = target.querySelectorAll('button'); const [btn] = target.querySelectorAll('button');
btn.click(); btn.click();
await Promise.resolve(); await Promise.resolve();
assert.deepEqual(log, ['init', {}, 'init', [], 'update', { x: 'hello' }, 'update', ['hello']]); assert.deepEqual(log, ['update', { x: 'hello' }, 'update', ['hello']]);
} }
}); });

Loading…
Cancel
Save