Skip to content
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

Refactor to TypeScript and ship types #71

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,11 @@

module.exports = {
root: true,
parser: 'babel-eslint',
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
ecmaFeatures: {
legacyDecorators: true,
},
ecmaVersion: 'latest',
},
plugins: ['ember'],
plugins: ['ember', '@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:prettier/recommended',
Expand All @@ -21,6 +17,15 @@ module.exports = {
},
rules: {},
overrides: [
// ts files
{
files: ['**/*.ts'],
extends: [
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
],
rules: {},
},
// node files
{
files: [
Expand Down
53 changes: 38 additions & 15 deletions addon/index.js → addon/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { TrackedMap } from 'tracked-maps-and-sets';

type PropertyDescriptorInit = PropertyDescriptor & { initializer: () => void };

const managedKeys = new Set();
const localStorageCache = new TrackedMap();

// like JSON.parse() but all returned objects are frozen
function jsonParseAndFreeze(json) {
function jsonParseAndFreeze(json: string) {
return JSON.parse(json, (key, value) =>
typeof value === 'object' ? Object.freeze(value) : value
);
Expand All @@ -22,39 +24,56 @@ window.addEventListener('storage', function ({ key, newValue }) {
return;
}

localStorageCache.set(key, jsonParseAndFreeze(newValue));
localStorageCache.set(key, jsonParseAndFreeze(newValue as string));
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we guard against newValue being null instead of doing a type assertion?

});

export default function localStorageDecoratorFactory(...args) {
export default function localStorageDecoratorFactory(
target: unknown,
propertyKey: PropertyKey
): void;

export default function localStorageDecoratorFactory(
customLocalStorageKey: string
): (
target: unknown,
propertyKey: PropertyKey,
descriptor: PropertyDescriptorInit
) => void;

export default function localStorageDecoratorFactory(
...args: [unknown, PropertyKey, PropertyDescriptorInit]
) {
const isDirectDecoratorInvocation = isElementDescriptor(...args);
const customLocalStorageKey = isDirectDecoratorInvocation
? undefined
: args[0];

function localStorageDecorator(target, key, descriptor) {
function localStorageDecorator(
...[target, key, descriptor]: [unknown, PropertyKey, PropertyDescriptorInit]
) {
const localStorageKey = customLocalStorageKey ?? key;

initalizeLocalStorageKey(localStorageKey);
initalizeLocalStorageKey(localStorageKey as string);

// register getter and setter
return {
get() {
return (
localStorageCache.get(localStorageKey) ??
(descriptor.initializer
(descriptor?.initializer
? descriptor.initializer.call(target)
: undefined)
);
},
set(value) {
set(value: unknown) {
const json = JSON.stringify(value);

// Update local storage cache. It must include a froozen copy the
// the value to prevent leaking state between different consumers.
localStorageCache.set(localStorageKey, jsonParseAndFreeze(json));

// Update local storage.
window.localStorage.setItem(localStorageKey, json);
window.localStorage.setItem(localStorageKey as string, json);
},
};
}
Expand All @@ -69,27 +88,31 @@ export function clearLocalStorageCache() {
localStorageCache.clear();
}

export function initalizeLocalStorageKey(key) {
export function initalizeLocalStorageKey(key: string) {
// Check if key is already managed. If it is not managed yet, initialize it
// in localStorageCache with the current value in local storage.
// Need to use a separate, not tracked data store to do this check
// because a tracked value (`localStorageCache`) must not be read
// before it is set.
if (!managedKeys.has(key)) {
managedKeys.add(key);
localStorageCache.set(
key,
jsonParseAndFreeze(window.localStorage.getItem(key))
);

const item = window.localStorage.getItem(key);
if (item === null) {
throw new Error();
}
localStorageCache.set(key, jsonParseAndFreeze(item));
}
}

// This will detect if the function arguments match the legacy decorator pattern
//
// Borrowed from the Ember Data source code:
// https://github.com/emberjs/data/blob/22a8f20e2f11ed82c85160944e976073dc530d8b/packages/model/addon/-private/util.ts#L5
function isElementDescriptor(...args) {
let [maybeTarget, maybeKey, maybeDescriptor] = args;
function isElementDescriptor(
...args: [unknown, PropertyKey, PropertyDescriptor]
): boolean {
const [maybeTarget, maybeKey, maybeDescriptor] = args;

return (
// Ensure we have the right number of args
Expand Down
2 changes: 2 additions & 0 deletions ember-cli-build.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ const EmberAddon = require('ember-cli/lib/broccoli/ember-addon');

module.exports = function (defaults) {
let app = new EmberAddon(defaults, {
'ember-cli-babel': { enableTypeScriptTransform: true },

// Add options here
});

Expand Down
4 changes: 4 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,8 @@

module.exports = {
name: require('./package').name,

options: {
'ember-cli-babel': { enableTypeScriptTransform: true },
},
};
13 changes: 12 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
"lint": "npm-run-all --aggregate-output --continue-on-error --parallel lint:*",
"lint:hbs": "ember-template-lint .",
"lint:js": "eslint .",
"lint:types": "tsc --noEmit",
"prepack": "tsc --project tsconfig.declarations.json",
"postpack": "rimraf declarations",
"start": "ember serve",
"test": "npm-run-all lint:* test:*",
"test:ember": "ember test",
Expand All @@ -32,7 +35,13 @@
"@ember/test-helpers": "^2.4.2",
"@glimmer/component": "^1.0.2",
"@glimmer/tracking": "^1.0.2",
"babel-eslint": "^10.1.0",
"@glint/environment-ember-loose": "^1.4.0",
"@glint/template": "^1.4.0",
"@tsconfig/ember": "^3.0.8",
"@types/qunit": "^2.19.10",
"@types/rsvp": "^4.0.9",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"broccoli-asset-rev": "^3.0.0",
"ember-auto-import": "^2.0.0",
"ember-cli": "~4.11.0",
Expand Down Expand Up @@ -63,6 +72,8 @@
"qunit-dom": "^2.0.0",
"release-it": "^15.0.0",
"release-it-lerna-changelog": "^5.0.0",
"rimraf": "^5.0.1",
"typescript": "^5.4.5",
"webpack": "^5.60.0"
},
"engines": {
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import localStorage from 'ember-local-storage-decorator';

export default class TestComponentComponent extends Component {
@localStorage
foo;
foo: unknown;

@localStorage
bar;
bar: unknown;

@action
updateFoo() {
Expand Down
14 changes: 14 additions & 0 deletions tests/dummy/app/config/environment.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Type declarations for
* import config from 'dummy/config/environment'
*/
declare const config: {
environment: string;
modulePrefix: string;
podModulePrefix: string;
locationType: 'history' | 'hash' | 'none';
rootURL: string;
APP: Record<string, unknown>;
};

export default config;
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,24 @@ module('Integration | Component | test-component', function (hooks) {
test('it works inside a component', async function (assert) {
await render(hbs`<TestComponent />`);
assert.dom().hasText('');
assert.equal(JSON.parse(window.localStorage.getItem('foo')), null);
assert.equal(JSON.parse(window.localStorage.getItem('foo')!), null);

await click('button');
assert.dom().hasText('foo');
assert.equal(JSON.parse(window.localStorage.getItem('foo')), 'foo');
assert.equal(JSON.parse(window.localStorage.getItem('foo')!), 'foo');
});

test('setting one property does not invalidate another', async function (assert) {
let invalidationCounter = {
const invalidationCounter = {
foo: 0,
bar: 0,
};
this.set('invalidationTracker', (property) => {
invalidationCounter[property]++;
});
this.set(
'invalidationTracker',
(property: keyof typeof invalidationCounter) => {
invalidationCounter[property]++;
}
);
await render(
hbs`<TestComponent @onInvalidation={{this.invalidationTracker}} />`
);
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ module('Unit | Decorator | @localStorage', function (hooks) {
module('default values', function () {
test('it defaults to null', function (assert) {
const klass = new (class {
@localStorage() foo;
@localStorage() foo: unknown;
})();

assert.equal(klass.foo, null);
Expand Down Expand Up @@ -46,7 +46,7 @@ module('Unit | Decorator | @localStorage', function (hooks) {
window.localStorage.setItem('foo', JSON.stringify('baz'));

const klass = new (class {
@localStorage() foo;
@localStorage() foo: string | undefined;
})();

assert.equal(klass.foo, 'baz');
Expand All @@ -56,19 +56,19 @@ module('Unit | Decorator | @localStorage', function (hooks) {
window.localStorage.setItem('foo', JSON.stringify([{ bar: 'baz' }]));

const klass = new (class {
@localStorage() foo;
@localStorage() foo: unknown[] | undefined;
})();

assert.deepEqual(klass.foo, [{ bar: 'baz' }]);
assert.ok(Object.isFrozen(klass.foo), 'freezes complex value');
assert.ok(Object.isFrozen(klass.foo[0]), 'deep freezes complex value');
assert.ok(Object.isFrozen(klass.foo![0]), 'deep freezes complex value');
});

test('supports custom local storage key', function (assert) {
window.localStorage.setItem('bar', JSON.stringify('baz'));

const klass = new (class {
@localStorage('bar') foo;
@localStorage('bar') foo: string | undefined;
})();

assert.equal(klass.foo, 'baz');
Expand All @@ -78,7 +78,7 @@ module('Unit | Decorator | @localStorage', function (hooks) {
module('setter', function () {
test('user can set a simple value', function (assert) {
const klass = new (class {
@localStorage() foo;
@localStorage() foo: string | undefined;
})();

klass.foo = 'bar';
Expand All @@ -87,7 +87,7 @@ module('Unit | Decorator | @localStorage', function (hooks) {

test('user can set a complex value', function (assert) {
const klass = new (class {
@localStorage() foo;
@localStorage() foo: unknown[] | undefined;
})();

klass.foo = [{ bar: 'baz' }];
Expand All @@ -96,22 +96,22 @@ module('Unit | Decorator | @localStorage', function (hooks) {

test('persistes simple value in local storage', function (assert) {
const klass = new (class {
@localStorage() foo;
@localStorage() foo: string | undefined;
})();

klass.foo = 'baz';
assert.equal(JSON.parse(window.localStorage.getItem('foo')), 'baz');
assert.equal(JSON.parse(window.localStorage.getItem('foo')!), 'baz');
});

test('persists complex value in local storage', function (assert) {
const klass = new (class {
@localStorage() foo;
@localStorage() foo: unknown[] | undefined;
})();

const value = [{ bar: 'baz' }];

klass.foo = value;
assert.deepEqual(JSON.parse(window.localStorage.getItem('foo')), value);
assert.deepEqual(JSON.parse(window.localStorage.getItem('foo')!), value);
assert.ok(Object.isFrozen(klass.foo), 'object is frozen');
assert.ok(Object.isFrozen(klass.foo[0]), 'object is deep frozen');
assert.notOk(klass.foo === value, 'object is a copy of the one set');
Expand All @@ -120,21 +120,21 @@ module('Unit | Decorator | @localStorage', function (hooks) {

test('supports custom local storage key', function (assert) {
const klass = new (class {
@localStorage('bar') foo;
@localStorage('bar') foo: unknown;
})();

klass.foo = 'baz';
assert.equal(JSON.parse(window.localStorage.getItem('bar')), 'baz');
assert.equal(JSON.parse(window.localStorage.getItem('bar')!), 'baz');
});
});

module('external changes', function () {
test('picks up changes caused by another class', function (assert) {
const klassA = new (class {
@localStorage() foo;
@localStorage() foo: unknown;
})();
const klassB = new (class {
@localStorage() foo;
@localStorage() foo: unknown;
})();

klassA.foo = 'bar';
Expand All @@ -143,7 +143,7 @@ module('Unit | Decorator | @localStorage', function (hooks) {

test('picks up changes caused by other tabs', async function (assert) {
const klass = new (class {
@localStorage() foo;
@localStorage() foo: unknown;
})();

// assert initial state
Expand All @@ -165,7 +165,7 @@ module('Unit | Decorator | @localStorage', function (hooks) {
test('developer can reinitialize a local storage key in tests', function (assert) {
// create a class, which will be used between multiple tests runs
class Foo {
@localStorage foo;
@localStorage foo: unknown;
}

// use the class in first test
Expand Down
Loading
Loading