Skip to content

Commit

Permalink
Add support for using using
Browse files Browse the repository at this point in the history
  • Loading branch information
andrewiggins committed Jul 14, 2023
1 parent 1ace900 commit 62c0fbd
Show file tree
Hide file tree
Showing 7 changed files with 128 additions and 68 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"license": "MIT",
"devDependencies": {
"@babel/core": "^7.22.8",
"@babel/plugin-proposal-explicit-resource-management": "^7.22.6",
"@babel/plugin-syntax-jsx": "^7.21.4",
"@babel/plugin-transform-modules-commonjs": "^7.22.5",
"@babel/plugin-transform-react-jsx": "^7.21.4",
Expand Down
6 changes: 3 additions & 3 deletions packages/react-transform/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,11 @@ function isValueMemberExpression(
);
}

const tryCatchTemplate = template.statements`var STOP_TRACKING_IDENTIFIER = HOOK_IDENTIFIER();
const tryCatchTemplate = template.statements`var STORE_IDENTIFIER = HOOK_IDENTIFIER();
try {
BODY
} finally {
STOP_TRACKING_IDENTIFIER();
STORE_IDENTIFIER.finishEffect();
}`;

function wrapInTryFinally(
Expand All @@ -191,7 +191,7 @@ function wrapInTryFinally(
const newFunction = t.cloneNode(path.node);
newFunction.body = t.blockStatement(
tryCatchTemplate({
STOP_TRACKING_IDENTIFIER: stopTrackingIdentifier,
STORE_IDENTIFIER: stopTrackingIdentifier,
HOOK_IDENTIFIER: get(state, getHookIdentifier)(),
BODY: t.isBlockStatement(path.node.body)
? path.node.body.body // TODO: Is it okay to elide the block statement here?
Expand Down
23 changes: 23 additions & 0 deletions packages/react-transform/test/browser/e2e.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,4 +212,27 @@ describe("React Signals babel transfrom - browser E2E tests", () => {
});
expect(scratch.innerHTML).to.equal("<div>Hello Jane</div>");
});

it("works with the `using` keyword", async () => {
const { App } = await createComponent(
`
import { useSignals } from "@preact/signals-react/runtime";
export function App({ name }) {
using _ = useSignals();
return <div>Hello {name.value}</div>;
}`,
// Disable our babel plugin for this example so the explicit resource management plugin handles this case
{ mode: "manual" }
);

const name = signal("John");
await render(<App name={name} />);
expect(scratch.innerHTML).to.equal("<div>Hello John</div>");

await act(() => {
name.value = "Jane";
});
expect(scratch.innerHTML).to.equal("<div>Hello Jane</div>");
});
});
88 changes: 44 additions & 44 deletions packages/react/runtime/src/auto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
import React from "react";
import jsxRuntime from "react/jsx-runtime";
import jsxRuntimeDev from "react/jsx-dev-runtime";
import { useSignals, wrapJsx } from "./index";
import { EffectStore, useSignals, wrapJsx } from "./index";

export interface ReactDispatcher {
useRef: typeof React.useRef;
Expand Down Expand Up @@ -103,50 +103,50 @@ import { createMachine } from "xstate";
// if it doesn't signal a change in the component rendering lifecyle (NOOP).
const dispatcherMachinePROD = createMachine({
id: "ReactCurrentDispatcher_PROD",
initial: "null",
states: {
null: {
on: {
pushDispatcher: "ContextOnlyDispatcher",
},
},
ContextOnlyDispatcher: {
on: {
renderWithHooks_Mount_ENTER: "HooksDispatcherOnMount",
renderWithHooks_Update_ENTER: "HooksDispatcherOnUpdate",
pushDispatcher_NOOP: "ContextOnlyDispatcher",
popDispatcher_NOOP: "ContextOnlyDispatcher",
},
},
HooksDispatcherOnMount: {
on: {
renderWithHooksAgain_ENTER: "HooksDispatcherOnRerender",
resetHooksAfterThrow_EXIT: "ContextOnlyDispatcher",
finishRenderingHooks_EXIT: "ContextOnlyDispatcher",
},
},
HooksDispatcherOnUpdate: {
on: {
renderWithHooksAgain_ENTER: "HooksDispatcherOnRerender",
resetHooksAfterThrow_EXIT: "ContextOnlyDispatcher",
finishRenderingHooks_EXIT: "ContextOnlyDispatcher",
use_ResumeSuspensedMount_NOOP: "HooksDispatcherOnMount",
},
},
HooksDispatcherOnRerender: {
on: {
renderWithHooksAgain_ENTER: "HooksDispatcherOnRerender",
resetHooksAfterThrow_EXIT: "ContextOnlyDispatcher",
finishRenderingHooks_EXIT: "ContextOnlyDispatcher",
},
},
},
id: "ReactCurrentDispatcher_PROD",
initial: "null",
states: {
null: {
on: {
pushDispatcher: "ContextOnlyDispatcher",
},
},
ContextOnlyDispatcher: {
on: {
renderWithHooks_Mount_ENTER: "HooksDispatcherOnMount",
renderWithHooks_Update_ENTER: "HooksDispatcherOnUpdate",
pushDispatcher_NOOP: "ContextOnlyDispatcher",
popDispatcher_NOOP: "ContextOnlyDispatcher",
},
},
HooksDispatcherOnMount: {
on: {
renderWithHooksAgain_ENTER: "HooksDispatcherOnRerender",
resetHooksAfterThrow_EXIT: "ContextOnlyDispatcher",
finishRenderingHooks_EXIT: "ContextOnlyDispatcher",
},
},
HooksDispatcherOnUpdate: {
on: {
renderWithHooksAgain_ENTER: "HooksDispatcherOnRerender",
resetHooksAfterThrow_EXIT: "ContextOnlyDispatcher",
finishRenderingHooks_EXIT: "ContextOnlyDispatcher",
use_ResumeSuspensedMount_NOOP: "HooksDispatcherOnMount",
},
},
HooksDispatcherOnRerender: {
on: {
renderWithHooksAgain_ENTER: "HooksDispatcherOnRerender",
resetHooksAfterThrow_EXIT: "ContextOnlyDispatcher",
finishRenderingHooks_EXIT: "ContextOnlyDispatcher",
},
},
},
});
```
*/

let stopTracking: (() => void) | null = null;
let store: EffectStore | null = null;
let lock = false;
let currentDispatcher: ReactDispatcher | null = null;

Expand All @@ -171,12 +171,12 @@ function installCurrentDispatcherHook() {
isEnteringComponentRender(currentDispatcherType, nextDispatcherType)
) {
lock = true;
stopTracking = useSignals();
store = useSignals();
lock = false;
} else if (
isExitingComponentRender(currentDispatcherType, nextDispatcherType)
) {
stopTracking?.();
store?.finishEffect();
}
},
});
Expand Down Expand Up @@ -325,7 +325,7 @@ function isExitingComponentRender(
): boolean {
return Boolean(
currentDispatcherType & BrowserClientDispatcherType &&
nextDispatcherType & ContextOnlyDispatcherType
nextDispatcherType & ContextOnlyDispatcherType
);
}

Expand Down
53 changes: 32 additions & 21 deletions packages/react/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,34 @@ export function wrapJsx<T>(jsx: T): T {
} as any as T;
}

const symDispose: unique symbol = (Symbol as any).dispose || Symbol.for("Symbol.dispose");

interface Effect {
_sources: object | undefined;
_start(): () => void;
_callback(): void;
_dispose(): void;
}

interface EffectStore {
updater: Effect;
export interface EffectStore {
effect: Effect;
subscribe(onStoreChange: () => void): () => void;
getSnapshot(): number;
finishEffect(): void;
[symDispose](): void;
}

let finishUpdate: (() => void) | undefined;

function setCurrentStore(store?: EffectStore) {
// end tracking for the current update:
if (finishUpdate) finishUpdate();
// start tracking the new update:
finishUpdate = store && store.effect._start();
}

const clearCurrentStore = () => setCurrentStore();

/**
* A redux-like store whose store value is a positive 32bit integer (a 'version').
*
Expand All @@ -51,20 +66,20 @@ interface EffectStore {
* @see https://github.com/reactjs/rfcs/blob/main/text/0214-use-sync-external-store.md
*/
function createEffectStore(): EffectStore {
let updater!: Effect;
let effectInstance!: Effect;
let version = 0;
let onChangeNotifyReact: (() => void) | undefined;

let unsubscribe = effect(function (this: Effect) {
updater = this;
effectInstance = this;
});
updater._callback = function () {
effectInstance._callback = function () {
version = (version + 1) | 0;
if (onChangeNotifyReact) onChangeNotifyReact();
};

return {
updater,
effect: effectInstance,
subscribe(onStoreChange) {
onChangeNotifyReact = onStoreChange;

Expand All @@ -87,32 +102,28 @@ function createEffectStore(): EffectStore {
getSnapshot() {
return version;
},
finishEffect() {
clearCurrentStore();
},
[symDispose]() {
clearCurrentStore();
}
};
}

let finalCleanup: Promise<void> | undefined;
let finishUpdate: (() => void) | undefined;

function setCurrentUpdater(updater?: Effect) {
// end tracking for the current update:
if (finishUpdate) finishUpdate();
// start tracking the new update:
finishUpdate = updater && updater._start();
}

const _queueMicroTask = Promise.prototype.then.bind(Promise.resolve());
const clearCurrentUpdater = () => setCurrentUpdater();

/**
* Custom hook to create the effect to track signals used during render and
* subscribe to changes to rerender the component when the signals change.
*/
export function useSignals(): () => void {
clearCurrentUpdater();
export function useSignals(): EffectStore {
clearCurrentStore();
if (!finalCleanup) {
finalCleanup = _queueMicroTask(() => {
finalCleanup = undefined;
clearCurrentUpdater();
clearCurrentStore();
});
}

Expand All @@ -123,9 +134,9 @@ export function useSignals(): () => void {

const store = storeRef.current;
useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
setCurrentUpdater(store.updater);
setCurrentStore(store);

return clearCurrentUpdater;
return store;
}

/**
Expand Down
23 changes: 23 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions test/browser/babel.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import transformReactJsx from "@babel/plugin-transform-react-jsx";
import { transform } from "@babel/standalone";
// @ts-expect-error
import signalsTransform from "@preact/signals-react-transform";
import explicitResourceManagement from "@babel/plugin-proposal-explicit-resource-management";

globalThis.transformSignalCode = function transformSignalCode(code, options) {
const signalsPluginConfig = [signalsTransform];
Expand All @@ -21,6 +22,7 @@ globalThis.transformSignalCode = function transformSignalCode(code, options) {
syntaxJsx,
[transformReactJsx, { runtime: "automatic" }],
[transformEsm, { importInterop: "none", loose: true, strict: true }],
explicitResourceManagement,
],
});

Expand Down

0 comments on commit 62c0fbd

Please sign in to comment.