From c9f501cb3132d66f7788a065c61d9f8b1a12664b Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Wed, 24 Jan 2024 13:21:06 +0300 Subject: [PATCH 1/3] [WIP] redux extension integration --- src/utils/reactive.ts | 26 +++++ src/utils/redux-devtools.ts | 200 ++++++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 src/utils/redux-devtools.ts diff --git a/src/utils/reactive.ts b/src/utils/reactive.ts index 0bde51e8..9a018091 100644 --- a/src/utils/reactive.ts +++ b/src/utils/reactive.ts @@ -5,6 +5,7 @@ */ import { scheduleRevalidate } from '@/utils/runtime'; import { isFn, isTag, isTagLike, debugContext } from '@/utils/shared'; +import { supportChromeExtension } from './redux-devtools'; export const asyncOpcodes = new WeakSet(); // List of DOM operations for each tag @@ -340,3 +341,28 @@ export function getTracker() { export function setTracker(tracker: Set | null) { currentTracker = tracker; } + +supportChromeExtension({ + get() { + const cells = {}; + DEBUG_CELLS.forEach((cell, index) => { + cells[`${cell._debugName}:${index}`] = cell._value; + }); + return cells; + }, + skipDispatch: 0, + set() { + console.log('set', ...arguments); + }, + on(timeLine: string, fn: () => any) { + console.log('on', timeLine, fn); + setTimeout(() => { + // debugger; + fn.call(this, 'updates', {}) + + }, 2000); + }, + trigger() { + console.log('trigger', ...arguments); + } +}); \ No newline at end of file diff --git a/src/utils/redux-devtools.ts b/src/utils/redux-devtools.ts new file mode 100644 index 00000000..df688222 --- /dev/null +++ b/src/utils/redux-devtools.ts @@ -0,0 +1,200 @@ +// inspired by https://www.npmjs.com/package/freezer-redux-devtools?activeTab=code +var ActionTypes = { + INIT: '@@INIT', + PERFORM_ACTION: 'PERFORM_ACTION', + TOGGLE_ACTION: 'TOGGLE_ACTION' +}; + +type Listener = () => void; + +/** + * Redux middleware to make freezer and devtools + * talk to each other. + * @param {Freezer} State Freezer's app state. + */ +export function FreezerMiddleware( State ){ + return function( next ){ + return function StoreEnhancer( someReducer, someState ){ + var commitedState = State.get(), + lastAction = 0, + /** + * Freezer reducer will trigger events on any + * devtool action to synchronize freezer's and + * devtool's states. + * + * @param {Object} state Current devtool state. + * @param {Object} action Action being dispatched. + * @return {Object} Freezer state after the action. + */ + reducer = function( state, action ){ + if( action.type == ActionTypes.INIT ){ + State.set( state || commitedState ); + } + else if( lastAction != ActionTypes.PERFORM_ACTION ) { + // Flag that we are dispatching to not + // to dispatch the same action twice + State.skipDispatch = 1; + State.trigger.apply( State, [ action.type ].concat( action.arguments || [] ) ); + } + // The only valid state is freezer's one. + return State.get(); + }, + store = next( reducer ), + liftedStore = store.liftedStore, + dtStore = store.devToolsStore || store.liftedStore, + + toolsDispatcher = dtStore.dispatch + ; + + // Override devTools store's dispatch, to set commitedState + // on Commit action. + dtStore.dispatch = function( action ){ + lastAction = action.type; + + // If we are using redux-devtools we need to reset the state + // to the last valid one manually + if( liftedStore && lastAction == ActionTypes.TOGGLE_ACTION ){ + var states = dtStore.getState().computedStates, + nextValue = states[ action.id - 1].state + ; + + State.set( nextValue ); + } + + toolsDispatcher.apply( dtStore, arguments ); + + return action; + }; + + // Dispatch any freezer "fluxy" event to let the devTools + // know about the update. + State.on('afterAll', function( reactionName ){ + if( reactionName == 'update') + return; + + // We don't dispatch if the flag is true + if( this.skipDispatch ) + this.skipDispatch = 0; + else { + var args = [].slice.call( arguments, 1 ); + store.dispatch({ type: reactionName, args: args }); + } + }); + + return store; + }; + }; +} + +/** + * Binds freezer store to the chrome's redux-devtools extension. + * @param {Freezer} State Freezer's app state + */ +export function supportChromeExtension( State ){ + var devtools = window.__REDUX_DEVTOOLS_EXTENSION__ + ? window.__REDUX_DEVTOOLS_EXTENSION__() + : (f) => f; + + compose( + FreezerMiddleware( State ), + devtools + )(createStore)( function( state ){ + return state; + }); +} + + +/** + * Creates a valid redux store. Copied directly from redux. + * https://github.com/rackt/redux + */ +function createStore(reducer: any, initialState: any) { + + + if (typeof reducer !== 'function') { + throw new Error('Expected the reducer to be a function.'); + } + + var currentReducer = reducer; + var currentState = initialState; + var listeners: Listener[] = []; + var isDispatching = false; + var ActionTypes = { + INIT: '@@redux/INIT' + }; + + function getState() { + return currentState; + } + + function subscribe(listener: Listener) { + listeners.push(listener); + var isSubscribed = true; + + return function unsubscribe() { + if (!isSubscribed) { + return; + } + + isSubscribed = false; + var index = listeners.indexOf(listener); + listeners.splice(index, 1); + }; + } + + function dispatch(action: { type: string | undefined }) { + if (typeof action.type === 'undefined') { + throw new Error('Actions may not have an undefined "type" property. ' + 'Have you misspelled a constant?'); + } + + if (isDispatching) { + throw new Error('Reducers may not dispatch actions.'); + } + + try { + isDispatching = true; + currentState = currentReducer(currentState, action); + } finally { + isDispatching = false; + } + + listeners.slice().forEach(function (listener) { + return listener(); + }); + return action; + } + + function replaceReducer(nextReducer: any) { + currentReducer = nextReducer; + dispatch({ type: ActionTypes.INIT }); + } + + // When a store is created, an "INIT" action is dispatched so that every + // reducer returns their initial state. This effectively populates + // the initial state tree. + dispatch({ type: ActionTypes.INIT }); + + return { + dispatch: dispatch, + subscribe: subscribe, + getState: getState, + replaceReducer: replaceReducer + }; +} + +/** + * Composes single-argument functions from right to left. + * Copied directly from redux. + * https://github.com/rackt/redux + */ +function compose() { + for (var _len = arguments.length, funcs = Array(_len), _key = 0; _key < _len; _key++) { + funcs[_key] = arguments[_key]; + } + + return function (arg: any) { + return funcs.reduceRight(function (composed, f) { + return f(composed); + }, arg); + }; +} From 4766f2b1a29a128b47a9c0e740f8e9c4f35e7113 Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Wed, 15 May 2024 19:56:17 +0300 Subject: [PATCH 2/3] + --- src/utils/reactive.ts | 59 ++++++++++++++++++++++--------------- src/utils/redux-devtools.ts | 2 +- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/src/utils/reactive.ts b/src/utils/reactive.ts index 9a018091..477175c8 100644 --- a/src/utils/reactive.ts +++ b/src/utils/reactive.ts @@ -51,6 +51,32 @@ function keysFor(obj: object): Map> { return cellsMap.get(obj)!; } +const result = supportChromeExtension({ + get() { + const cells = {}; + getCells().forEach((cell, index) => { + cells[`${cell._debugName}:${index}`] = cell._value; + }); + // console.log('get', cells); + return cells; + }, + skipDispatch: 0, + set() { + console.log('set', ...arguments); + }, + on(timeLine: string, fn: () => any) { + console.log('on', timeLine, fn); + setTimeout(() => { + // debugger; + fn.call(this, 'updates', {}) + + }, 2000); + }, + trigger() { + console.log('trigger', ...arguments); + } +}); + export function tracked( klass: any, key: string, @@ -120,6 +146,9 @@ export class Cell { this._debugName = debugContext(debugName); DEBUG_CELLS.add(this); } + result.dispatch({ + type: 'CELL_CREATED', + }); } get value() { if (currentTracker !== null) { @@ -134,6 +163,9 @@ export class Cell { this._value = value; tagsToRevalidate.add(this); scheduleRevalidate(); + result.dispatch({ + type: 'CELL_UPDATED', + }); } } @@ -342,27 +374,6 @@ export function setTracker(tracker: Set | null) { currentTracker = tracker; } -supportChromeExtension({ - get() { - const cells = {}; - DEBUG_CELLS.forEach((cell, index) => { - cells[`${cell._debugName}:${index}`] = cell._value; - }); - return cells; - }, - skipDispatch: 0, - set() { - console.log('set', ...arguments); - }, - on(timeLine: string, fn: () => any) { - console.log('on', timeLine, fn); - setTimeout(() => { - // debugger; - fn.call(this, 'updates', {}) - - }, 2000); - }, - trigger() { - console.log('trigger', ...arguments); - } -}); \ No newline at end of file + + +console.log('result', result); \ No newline at end of file diff --git a/src/utils/redux-devtools.ts b/src/utils/redux-devtools.ts index df688222..cf460b7b 100644 --- a/src/utils/redux-devtools.ts +++ b/src/utils/redux-devtools.ts @@ -95,7 +95,7 @@ export function supportChromeExtension( State ){ ? window.__REDUX_DEVTOOLS_EXTENSION__() : (f) => f; - compose( + return compose( FreezerMiddleware( State ), devtools )(createStore)( function( state ){ From 67bb19d5cdc73c756eb22fd3efc2e591defe904f Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Wed, 15 May 2024 20:34:20 +0300 Subject: [PATCH 3/3] + --- src/utils/reactive.ts | 21 +++++++++++++++------ src/utils/shared.ts | 1 + src/utils/vm.ts | 4 +++- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/utils/reactive.ts b/src/utils/reactive.ts index 477175c8..3dc47faa 100644 --- a/src/utils/reactive.ts +++ b/src/utils/reactive.ts @@ -4,7 +4,7 @@ We explicitly update DOM only when it's needed and only if tags are changed. */ import { scheduleRevalidate } from '@/utils/runtime'; -import { isFn, isTag, isTagLike, debugContext } from '@/utils/shared'; +import { isFn, isTag, isTagLike, debugContext, ALIVE_CELLS } from '@/utils/shared'; import { supportChromeExtension } from './redux-devtools'; export const asyncOpcodes = new WeakSet(); @@ -53,11 +53,18 @@ function keysFor(obj: object): Map> { const result = supportChromeExtension({ get() { - const cells = {}; - getCells().forEach((cell, index) => { - cells[`${cell._debugName}:${index}`] = cell._value; + const cells: Record = {}; + const allCells: Set = new Set(); + Array.from(ALIVE_CELLS).forEach((_cell) => { + const cell = _cell as MergedCell; + const nestedCells = Array.from(cell.relatedCells ?? []); + nestedCells.forEach((cell) => { + allCells.add(cell); + }); + }); + allCells.forEach((cell) => { + cells[cell._debugName!] = cell._value; }); - // console.log('get', cells); return cells; }, skipDispatch: 0, @@ -131,6 +138,8 @@ export function setIsRendering(value: boolean) { function tracker() { return new Set(); } + +let COUNTER = 0; // "data" cell, it's value can be updated, and it's used to create derived cells export class Cell { _value!: T; @@ -143,7 +152,7 @@ export class Cell { constructor(value: T, debugName?: string) { this._value = value; if (IS_DEV_MODE) { - this._debugName = debugContext(debugName); + this._debugName = `${debugContext(debugName)}:${COUNTER++}`; DEBUG_CELLS.add(this); } result.dispatch({ diff --git a/src/utils/shared.ts b/src/utils/shared.ts index fd4a8824..022c1252 100644 --- a/src/utils/shared.ts +++ b/src/utils/shared.ts @@ -14,6 +14,7 @@ export const $args = 'args' as const; export const $_debug_args = '_debug_args' as const; export const $fwProp = '$fw' as const; export const noop = () => {}; +export const ALIVE_CELLS = new Set(); export const IN_SSR_ENV = diff --git a/src/utils/vm.ts b/src/utils/vm.ts index 3e9cda8f..61be4bdf 100644 --- a/src/utils/vm.ts +++ b/src/utils/vm.ts @@ -9,7 +9,7 @@ import { opsFor, inNewTrackingFrame, } from './reactive'; -import { isFn } from './shared'; +import { ALIVE_CELLS, isFn } from './shared'; type maybeDestructor = undefined | (() => void); type maybePromise = undefined | Promise; @@ -95,7 +95,9 @@ export function opcodeFor(tag: AnyCell, op: tagOp) { evaluateOpcode(tag, op); const ops = opsFor(tag)!; ops.push(op); + ALIVE_CELLS.add(tag); return () => { + ALIVE_CELLS.delete(tag); // console.info(`Removing Updating Opcode for ${tag._debugName}`, tag); const index = ops.indexOf(op); if (index > -1) {