1import {
2	ImmerState,
3	Drafted,
4	ES5ArrayState,
5	ES5ObjectState,
6	each,
7	has,
8	isDraft,
9	latest,
10	DRAFT_STATE,
11	is,
12	loadPlugin,
13	ImmerScope,
14	ProxyType,
15	getCurrentScope,
16	die,
17	markChanged,
18	objectTraps,
19	ownKeys,
20	getOwnPropertyDescriptors
21} from "../internal"
22
23type ES5State = ES5ArrayState | ES5ObjectState
24
25export function enableES5() {
26	function willFinalizeES5_(
27		scope: ImmerScope,
28		result: any,
29		isReplaced: boolean
30	) {
31		if (!isReplaced) {
32			if (scope.patches_) {
33				markChangesRecursively(scope.drafts_![0])
34			}
35			// This is faster when we don't care about which attributes changed.
36			markChangesSweep(scope.drafts_)
37		}
38		// When a child draft is returned, look for changes.
39		else if (
40			isDraft(result) &&
41			(result[DRAFT_STATE] as ES5State).scope_ === scope
42		) {
43			markChangesSweep(scope.drafts_)
44		}
45	}
46
47	function createES5Draft(isArray: boolean, base: any) {
48		if (isArray) {
49			const draft = new Array(base.length)
50			for (let i = 0; i < base.length; i++)
51				Object.defineProperty(draft, "" + i, proxyProperty(i, true))
52			return draft
53		} else {
54			const descriptors = getOwnPropertyDescriptors(base)
55			delete descriptors[DRAFT_STATE as any]
56			const keys = ownKeys(descriptors)
57			for (let i = 0; i < keys.length; i++) {
58				const key: any = keys[i]
59				descriptors[key] = proxyProperty(
60					key,
61					isArray || !!descriptors[key].enumerable
62				)
63			}
64			return Object.create(Object.getPrototypeOf(base), descriptors)
65		}
66	}
67
68	function createES5Proxy_<T>(
69		base: T,
70		parent?: ImmerState
71	): Drafted<T, ES5ObjectState | ES5ArrayState> {
72		const isArray = Array.isArray(base)
73		const draft = createES5Draft(isArray, base)
74
75		const state: ES5ObjectState | ES5ArrayState = {
76			type_: isArray ? ProxyType.ES5Array : (ProxyType.ES5Object as any),
77			scope_: parent ? parent.scope_ : getCurrentScope(),
78			modified_: false,
79			finalized_: false,
80			assigned_: {},
81			parent_: parent,
82			// base is the object we are drafting
83			base_: base,
84			// draft is the draft object itself, that traps all reads and reads from either the base (if unmodified) or copy (if modified)
85			draft_: draft,
86			copy_: null,
87			revoked_: false,
88			isManual_: false
89		}
90
91		Object.defineProperty(draft, DRAFT_STATE, {
92			value: state,
93			// enumerable: false <- the default
94			writable: true
95		})
96		return draft
97	}
98
99	// property descriptors are recycled to make sure we don't create a get and set closure per property,
100	// but share them all instead
101	const descriptors: {[prop: string]: PropertyDescriptor} = {}
102
103	function proxyProperty(
104		prop: string | number,
105		enumerable: boolean
106	): PropertyDescriptor {
107		let desc = descriptors[prop]
108		if (desc) {
109			desc.enumerable = enumerable
110		} else {
111			descriptors[prop] = desc = {
112				configurable: true,
113				enumerable,
114				get(this: any) {
115					const state = this[DRAFT_STATE]
116					if (__DEV__) assertUnrevoked(state)
117					// @ts-ignore
118					return objectTraps.get(state, prop)
119				},
120				set(this: any, value) {
121					const state = this[DRAFT_STATE]
122					if (__DEV__) assertUnrevoked(state)
123					// @ts-ignore
124					objectTraps.set(state, prop, value)
125				}
126			}
127		}
128		return desc
129	}
130
131	// This looks expensive, but only proxies are visited, and only objects without known changes are scanned.
132	function markChangesSweep(drafts: Drafted<any, ImmerState>[]) {
133		// The natural order of drafts in the `scope` array is based on when they
134		// were accessed. By processing drafts in reverse natural order, we have a
135		// better chance of processing leaf nodes first. When a leaf node is known to
136		// have changed, we can avoid any traversal of its ancestor nodes.
137		for (let i = drafts.length - 1; i >= 0; i--) {
138			const state: ES5State = drafts[i][DRAFT_STATE]
139			if (!state.modified_) {
140				switch (state.type_) {
141					case ProxyType.ES5Array:
142						if (hasArrayChanges(state)) markChanged(state)
143						break
144					case ProxyType.ES5Object:
145						if (hasObjectChanges(state)) markChanged(state)
146						break
147				}
148			}
149		}
150	}
151
152	function markChangesRecursively(object: any) {
153		if (!object || typeof object !== "object") return
154		const state: ES5State | undefined = object[DRAFT_STATE]
155		if (!state) return
156		const {base_, draft_, assigned_, type_} = state
157		if (type_ === ProxyType.ES5Object) {
158			// Look for added keys.
159			// probably there is a faster way to detect changes, as sweep + recurse seems to do some
160			// unnecessary work.
161			// also: probably we can store the information we detect here, to speed up tree finalization!
162			each(draft_, key => {
163				if ((key as any) === DRAFT_STATE) return
164				// The `undefined` check is a fast path for pre-existing keys.
165				if ((base_ as any)[key] === undefined && !has(base_, key)) {
166					assigned_[key] = true
167					markChanged(state)
168				} else if (!assigned_[key]) {
169					// Only untouched properties trigger recursion.
170					markChangesRecursively(draft_[key])
171				}
172			})
173			// Look for removed keys.
174			each(base_, key => {
175				// The `undefined` check is a fast path for pre-existing keys.
176				if (draft_[key] === undefined && !has(draft_, key)) {
177					assigned_[key] = false
178					markChanged(state)
179				}
180			})
181		} else if (type_ === ProxyType.ES5Array) {
182			if (hasArrayChanges(state as ES5ArrayState)) {
183				markChanged(state)
184				assigned_.length = true
185			}
186
187			if (draft_.length < base_.length) {
188				for (let i = draft_.length; i < base_.length; i++) assigned_[i] = false
189			} else {
190				for (let i = base_.length; i < draft_.length; i++) assigned_[i] = true
191			}
192
193			// Minimum count is enough, the other parts has been processed.
194			const min = Math.min(draft_.length, base_.length)
195
196			for (let i = 0; i < min; i++) {
197				// Only untouched indices trigger recursion.
198				if (assigned_[i] === undefined) markChangesRecursively(draft_[i])
199			}
200		}
201	}
202
203	function hasObjectChanges(state: ES5ObjectState) {
204		const {base_, draft_} = state
205
206		// Search for added keys and changed keys. Start at the back, because
207		// non-numeric keys are ordered by time of definition on the object.
208		const keys = ownKeys(draft_)
209		for (let i = keys.length - 1; i >= 0; i--) {
210			const key: any = keys[i]
211			if (key === DRAFT_STATE) continue
212			const baseValue = base_[key]
213			// The `undefined` check is a fast path for pre-existing keys.
214			if (baseValue === undefined && !has(base_, key)) {
215				return true
216			}
217			// Once a base key is deleted, future changes go undetected, because its
218			// descriptor is erased. This branch detects any missed changes.
219			else {
220				const value = draft_[key]
221				const state: ImmerState = value && value[DRAFT_STATE]
222				if (state ? state.base_ !== baseValue : !is(value, baseValue)) {
223					return true
224				}
225			}
226		}
227
228		// At this point, no keys were added or changed.
229		// Compare key count to determine if keys were deleted.
230		const baseIsDraft = !!base_[DRAFT_STATE as any]
231		return keys.length !== ownKeys(base_).length + (baseIsDraft ? 0 : 1) // + 1 to correct for DRAFT_STATE
232	}
233
234	function hasArrayChanges(state: ES5ArrayState) {
235		const {draft_} = state
236		if (draft_.length !== state.base_.length) return true
237		// See #116
238		// If we first shorten the length, our array interceptors will be removed.
239		// If after that new items are added, result in the same original length,
240		// those last items will have no intercepting property.
241		// So if there is no own descriptor on the last position, we know that items were removed and added
242		// N.B.: splice, unshift, etc only shift values around, but not prop descriptors, so we only have to check
243		// the last one
244		const descriptor = Object.getOwnPropertyDescriptor(
245			draft_,
246			draft_.length - 1
247		)
248		// descriptor can be null, but only for newly created sparse arrays, eg. new Array(10)
249		if (descriptor && !descriptor.get) return true
250		// For all other cases, we don't have to compare, as they would have been picked up by the index setters
251		return false
252	}
253
254	function hasChanges_(state: ES5State) {
255		return state.type_ === ProxyType.ES5Object
256			? hasObjectChanges(state)
257			: hasArrayChanges(state)
258	}
259
260	function assertUnrevoked(state: any /*ES5State | MapState | SetState*/) {
261		if (state.revoked_) die(3, JSON.stringify(latest(state)))
262	}
263
264	loadPlugin("ES5", {
265		createES5Proxy_,
266		willFinalizeES5_,
267		hasChanges_
268	})
269}
270