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

Typescript) fix errors #202

Open
wants to merge 247 commits into
base: main
Choose a base branch
from
Open

Typescript) fix errors #202

wants to merge 247 commits into from

Conversation

phillipc
Copy link
Contributor

@phillipc phillipc commented Jan 12, 2025

As announced, we have started to make TKO more type-safe. To get a startpoint, we have set the tsconfig as softly as possible and continue to allow "any" types.

We (@Auge19 , @mcselle ) validate the typescript code with tsc. The current status is only 313 errors left. We began with 7410 errors. With this draft PR we would like to get feedback and start our own review. The goal is 0 errors with tsc. If this PR is ready and merged, we would like to continue with ESLINT.

All tests continue to pass.

phillipc and others added 30 commits December 20, 2024 21:45
add some types
#we should remove jasmine..
switch d.ts-version for jasmine (the project used jasmine 1.3)
1) [] => new Array
2) childNodes => children
3) extend some global type-defs
fix wrong jasmine imports
fix method signature
Copy link
Member

@brianmhunt brianmhunt left a comment

Choose a reason for hiding this comment

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

A few more comments, looking really great. The main concern I've spotted is the loss of info changing assertions from .childNodes to .children. Would be grateful for some thoughts on that. Thank you, and I'll keep turning back to look at this

new AttributeProvider()
]
}),
provider: provider,
Copy link
Member

Choose a reason for hiding this comment

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

can use the shorthand provider, in place of provider: provider,, which would be consistent with filters, below, and in any case shorter. Not needed, just noting

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@@ -52,6 +54,7 @@ const builder = new Builder({
]
})

// @ts-ignore: Build-Parameter
Copy link
Member

Choose a reason for hiding this comment

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

Perhaps this would work, stronger than an ignore?

declare const BUILD VERSION: string

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

o: () => ObservableArray // For jsxBehaviors Test
}

//Jasmine and Mocha define duplicated functions, is a problem for the type system
Copy link
Member

Choose a reason for hiding this comment

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

yes, those or playwright.dev or bun.sh. They're pretty tightly tied to Jasmine right now, so it'd be an effort, but homogenizing the runner would help a lot.

@@ -244,14 +244,15 @@ describe('Deferred bindings', function () {

// Value is initially true, so nodes are retained
applyBindings({ someItem: someItem, counter: 0 }, testNode)
expect(testNode.childNodes[0].childNodes[0].tagName.toLowerCase()).toEqual('span')

expect(testNode.children[0].children[0].tagName.toLowerCase()).toEqual('span')
Copy link
Member

Choose a reason for hiding this comment

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

ah right, typing. I wonder if .childNodes, being broader, is a stronger test and we are introducing the possibility of false negative tests here i.e. missing failures where childNodes would have additional information that would reveal failures.

There are a few cases to consider:

  1. .childNodes having text/virtual nodes that it shouldn't and the test would fail, but .children test would still pass
  2. .children ignores/collapses important white-space that .childNodes picks up

Which is all to say, for the sake of type safety this would make the tests weaker and if/as we change TKO we may introduce bugs that the stronger variants may pick up.

Not to say I'm entirely opposed to the change, but wondering if perhaps there's an alternative

testNode.childNodes[0].childNodes[0]['tagName']['toLowerCase']()

Properly there'd be type guarding in place, but that'd be a heftier ask I feel.

Any thoughts come to mind on this concern?

}

export interface BindingContext<T = any> {
ko: any; // typeof ko;
Copy link
Member

Choose a reason for hiding this comment

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

not needed here, but it would be nice to even have a placeholder like KnockoutInternalInstance = any so we could track these down later and strengthen the types

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point, done!

// The bindingContext constructor is only called directly to create the root context. For child
// contexts, use bindingContext.createChildContext or bindingContext.extend.
export function bindingContext (dataItemOrAccessor, parentContext, dataItemAlias, extendCallback, settings) {
export const bindingContext = function<T>(dataItemOrAccessor: any, parentContext?: BindingContext, dataItemAlias?: string, extendCallback?: BindingContextExtendCallback<T>, settings?: BindingContextSetting) {
Copy link
Member

Choose a reason for hiding this comment

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

making the function anonymous means the name won't show up in stack traces. I'm not sure but we might be able to set bindingContext.name = 'bindingContext' (or perhaps Object.defineProperty, but same idea) and then it's show up in stack traces, but I've never tested this.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I will check this. This style of implementation allows the as unknown as bindingContext magic, so the returned "objects" can be typed. If we convert this to proper TS/JS-classes in the future, this would be solved anyway.

Copy link
Member

Choose a reason for hiding this comment

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

Thanks. It's not a blocker IMO, just an observation since the stack trace is pretty well named already.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have been analyzing Knockout.js applications since 2015, this is an important point 💯

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I found an easy solution in Typescript:

export const bindingContext = function bindingContextFactory<T>(....)

This results in JS:

export const bindingContext = function bindingContextFactory(dataItemOrAccessor, parentContext, dataItemAlias, extendCallback, settings)

The name just has to be different, otherwise tsc will remove it!

Copy link
Member

@brianmhunt brianmhunt left a comment

Choose a reason for hiding this comment

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

👍 Great changes thank you. Will keep checking back and reviewing!

options: {
bindingGlobals: defaultOptions.global,
bindingStringPreparsers: [functionRewrite]
}
})

// @ts-ignore: Build-Parameter
Copy link
Member

Choose a reason for hiding this comment

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

also declare const

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

expect(clickedVM).toEqual(vm());

// set the view-model to a new object
vm({ someProp: observableConstructor('My new prop value'), checkVM: checkVM });
expect(testNode.childNodes[0].childNodes[0].value).toEqual('My new prop value');
expect(child.value).toEqual('My new prop value');
Copy link
Member

Choose a reason for hiding this comment

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

To make sure it has the latest DOM structure, child should be a function i.e.

const child = () => testNode.childNodes[0].childNodes[0] as HTMLInputElement

... because part of the assertion here is the DOM structure i.e. testNode.childNodes[0].childNodes[0] may no longer be child here, and if that were the case we'd want this test to fail. That failure wouldn't be visible if input is a reference to the input.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@phillipc
Copy link
Contributor Author

phillipc commented Mar 5, 2025

@brianmhunt: The reactivation of CodeQL worked so well that it directly filled all PRs with warnings. For now, I left it at a trigger via cron and deactiviated it on any PR. We would first have to configure a “meaningful” baseline for CodeQL.

Copy link
Member

@brianmhunt brianmhunt left a comment

Choose a reason for hiding this comment

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

Some more comments, great progress, thank you! I will return for more

@@ -27,7 +27,8 @@ import {bindings as coreBindings} from '@tko/binding.core'
import '@tko/utils/helpers/jasmine-13-helper'

describe('Binding: If', function () {
beforeEach(jasmine.prepareTestNode)
var testNode : HTMLElement
Copy link
Member

Choose a reason for hiding this comment

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

prefer let to avoid hoisting?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I would fix this globally with eslint in another PR afterwards. It's already prepared in @Auge19's branch.

@@ -26,7 +26,8 @@ import {
import '@tko/utils/helpers/jasmine-13-helper';

describe('else inside an if binding', function () {
beforeEach(jasmine.prepareTestNode);
var testNode : HTMLElement
Copy link
Member

Choose a reason for hiding this comment

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

prefer let (not a blocker, just noting)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@@ -281,7 +318,7 @@ export class ForEachBinding extends AsyncBindingHandler {
if (as) {
return v => this._contextExtensions($ctx.extend({ [as]: v }))
} else {
return v => $ctx.createChildContext(v, null, ctx => this._contextExtensions(ctx))
return v => $ctx.createChildContext(v, undefined, ctx => this._contextExtensions(ctx))
Copy link
Member

Choose a reason for hiding this comment

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

I assume our tests will cover the consequences of this, and that it's fine, but just noting that it's a somewhat semantic change in fairly fickle code. Probably ok, but noting just in case.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It should be covered. I'm always a bit unsure about this situations in JS. Is there a general rule like "undefinied is better than null" in JS/TS-Styling-Guides?

Copy link
Member

Choose a reason for hiding this comment

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

The general rule is that they are different and fickle and all a bit insane e.g.

undefined == null is true // equality with type-cast
undefined === null is false // strictly inequal
null + 1 is 1
undefined + 1 is NaN

function x (def = 1) { return def }
x(null) is null
x(undefined) is 1

typeof null is "object"
typeof undefined is "undefined"

So it's often dangerous to change old code from null to undefined and vice versa because of these (and many more) subtle differences that may not be immediately apparent, particularly if they're not covered by tests.

If we can keep this as null, that'd be safer.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I looked at the specific location again. The type definition is taken from the official "knockout.d.ts": dataItemAlias?: string The value is also "safely" handled with if (dataItemAlias) L116 at bindingContext.ts.

At this point I would call it a type error in foreach.ts. In general, however, I agree with you, we have to validate something like this case-by-case. Unfortunately JS allowed both. If you like, we can leave it at dataItemAlias?: string | null.

deleted: any[]
}

interface ChangeAddItem{
Copy link
Member

Choose a reason for hiding this comment

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

A stronger type here is:

interface ChangeAddBatchItem {
  status: 'added'
  index: number
  isBatch: true
  values?: any[]
}

interface ChangeAddOneItem {
  status: 'added'
  index: number
  isBatch?: false
  value: any
}

type ChangeAddItem = 
  | ChangeAddBatchItem
  | ChangeAddOneItem

then one can typeguard on this i.e.

const c: ChangeAddItem = ...
if (c.isBatch) {
  // c is ChangeAddBatchItem
  c.values
  c.value // TypeError
} else {
  // c is ChangeAddOneItem
  c.value
  c.values // TypeError
}

Not strictly needed, but good to have

https://www.typescriptlang.org/play/?#code/FASwdgLgpgTgZgQwMZQAQGEAWCwHMoCCAJkQEIIRKYCS0AtqgN7CqoDOEFArmwFyoByBCShEBLVOCJQAHvzBc6AI1gSQbcpUz8IMLlAkA3BABt9bAPz8cATwDaAXWABfYKEixEKDNjyESAPJgULRQDMysHNx8gsLSYmpg0nKoCsqqrOqaVFaoiCZsBqzGZlDWYDYubhA2AA5oWDj4xEShDAC8qBIAPj5N-mQUVG09fX4tQSH0bkgA9mAcqEj8jeMkbaidjM6oCGy7FaBwqAAUSAB0WUOYAJRMEgD0D0uS+6vNJNk006wXJfqoJ6oAAqdSgAFEYDBZjAJH9TOZAc9ZgBrFyoKAFNARJEvdRjD5ESYjX7nf5oIGouFkhFQfZA0H1SHQ2HOIA

Choose a reason for hiding this comment

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

Done

// Utilities
const MAX_LIST_SIZE = 9007199254740991

// from https://github.com/jonschlinkert/is-plain-object
function isPlainObject (o) {
function isPlainObject (o): o is Object {
Copy link
Member

Choose a reason for hiding this comment

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

The canonical object type is o is Record<string, any>, since Object is the parent of Array, Function, and many other classes.

Choose a reason for hiding this comment

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

Done

watchForSelectionChangeEvent (element, ieUpdateModel) {

// All variables are not found!
watchForSelectionChangeEvent (element?) {
Copy link
Member

Choose a reason for hiding this comment

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

it's unclear what's happening here and it doesn't seem related to the change of types so we might consider a separate PR to make sure the implications are well understood.

Could we break this particular change into a separate PR?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

At this point we noticed through the typing that the old IE 8/9 logic could not have worked at all. We made it tsc compliant for now and added a FIXME maker.

I think that we can in a later separate pr, remove all IE8 to IE10-Logic. IE11 could be discussed.

Copy link
Contributor Author

@phillipc phillipc Mar 6, 2025

Choose a reason for hiding this comment

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

element can be undefinied , handler is never defined, ieUpdateModel will not be handed over

export default {
init: function (element, valueAccessor, allBindings, viewModel, bindingContext) {
init: function (element, valueAccessor, allBindings, viewModel, bindingContext: BindingContext) { // allBindings and viewModel not used!
Copy link
Member

Choose a reason for hiding this comment

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

We could use the convention to start unused vars with _ e.g.

  init: function (element, valueAccessor, _allBindings, _viewModel, bindingContext: BindingContext) {

then the comment isn't needed and in many editors the variable will have a different color for the unused var

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@M-Kirchhoff has this adapted.

Actually, we should collect such things in a general TKO style guide.

Copy link
Member

@brianmhunt brianmhunt left a comment

Choose a reason for hiding this comment

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

I'm up to 55/193 files, making progress. Thank you!

@@ -33,7 +33,8 @@ import '@tko/utils/helpers/jasmine-13-helper';
describe('Binding dependencies', function () {
var bindingHandlers

beforeEach(jasmine.prepareTestNode);
var testNode : HTMLElement
Copy link
Member

Choose a reason for hiding this comment

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

prefer let

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done, changed in 44 files ;) Let's do the rest later with eslint, it can be automated.

self.$parent = parentContext.$data
self.$parents = (parentContext.$parents || []).slice(0)
self.$parent = parentContext?.$data
self.$parents = (parentContext?.$parents || []).slice(0)
Copy link
Member

Choose a reason for hiding this comment

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

I assume $parents cannot be a falsy e.g. false or 0, but to be safe it's generally better to use ...?.$parents ?? []

(not needed, just noting)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@@ -147,30 +196,30 @@ Object.assign(bindingContext.prototype, {
extend (properties) {
// If the parent context references an observable view model, "_subscribable" will always be the
// latest view model object. If not, "_subscribable" isn't set, and we can use the static "$data" value.
return new bindingContext(inheritParentIndicator, this, null, function (self, parentContext) {
return new bindingContext(inheritParentIndicator, this, undefined, function (self, parentContext) {
Copy link
Member

Choose a reason for hiding this comment

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

I'm assuming this has no material difference, but it's semantic and not just a type change, so we want to be mindful of knock-on consequences.

// We can only do something meaningful for elements and comment nodes (in particular, not text nodes, as IE can't store domdata for them)
if (node && (node.nodeType === 1 || node.nodeType === 8)) {
return storedBindingContextForNode(node)
}
}

export function dataFor (node) {
export function dataFor<T = any>(node: Node): T | undefined {
Copy link
Member

Choose a reason for hiding this comment

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

I'm not clear on what this generic does. Which is ok, and maybe I'll see it when it's used, but just noting.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Comes from the official d.ts files.. Only for the return value relevant. We intended to keep things as similar as possible. In reality, we had to compromise to the current state of implementation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

With make dts we can already generate definitions quite well. We would just need a delivery process later.

triggerEvent(testNode.childNodes[0], 'click')
triggerEvent(testNode.children[0], 'click')
triggerEvent(testNode.children[0], 'click')
triggerEvent(testNode.children[0], 'click')
Copy link
Member

Choose a reason for hiding this comment

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

I think all these .children[0] are ok replacing .childNodes[0]. The only case where there'd be a false positive is if there were two nodes and .childNodes[0] !== .children[0] and clicking on the .children[0] created a false positive. That doesn't seem likely here, so I'm ok with it, just noting.

@@ -281,7 +318,7 @@ export class ForEachBinding extends AsyncBindingHandler {
if (as) {
return v => this._contextExtensions($ctx.extend({ [as]: v }))
} else {
return v => $ctx.createChildContext(v, null, ctx => this._contextExtensions(ctx))
return v => $ctx.createChildContext(v, undefined, ctx => this._contextExtensions(ctx))
Copy link
Member

Choose a reason for hiding this comment

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

The general rule is that they are different and fickle and all a bit insane e.g.

undefined == null is true // equality with type-cast
undefined === null is false // strictly inequal
null + 1 is 1
undefined + 1 is NaN

function x (def = 1) { return def }
x(null) is null
x(undefined) is 1

typeof null is "object"
typeof undefined is "undefined"

So it's often dangerous to change old code from null to undefined and vice versa because of these (and many more) subtle differences that may not be immediately apparent, particularly if they're not covered by tests.

If we can keep this as null, that'd be safer.

@@ -33,29 +27,29 @@ describe('Array to DOM node children mapping', function () {
}
setDomNodeChildrenFromArrayMapping(testNode, array, mapping)
expect(testNode.childNodes.length).toEqual(4)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@brianmhunt This is also a useful methodology to deal with the assertion problem in the ChildNodes/Children adaptation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants