Skip to content

Create signal implementations that satisfies FrameworkSignal. Simplify SignalKinds.#1017

Closed
ditman wants to merge 3 commits intogoogle:avac-generic-signalsfrom
ditman:avac-generic-signals
Closed

Create signal implementations that satisfies FrameworkSignal. Simplify SignalKinds.#1017
ditman wants to merge 3 commits intogoogle:avac-generic-signalsfrom
ditman:avac-generic-signals

Conversation

@ditman
Copy link
Copy Markdown
Collaborator

@ditman ditman commented Mar 27, 2026

Description

Make SignalKinds just a holder for "readonly" and "writable" signal types, and use that in the FrameworkSignal type.

Users need to define their FrameworkSignal type as satisfies FrameworkSignal to prevent TS from erasing the internal types (similar to what we do with the component props!).

Split tests into 3 files: 2 for per-framework initialization, and a common one that runs the same tests across both implementations. The verification code remains the same as in the OG commit.

There's some shenanigans with tsc merging all our test files together and seeing the Preact and Angular types colliding with each other, and tests are not exactly identical to what users would define, but this is the general idea.

(I don't know if this does everything we want though!)

Pre-launch Checklist

If you need help, consider asking for advice on the discussion board.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the FrameworkSignal abstraction and its type-mapping system to use conditional types, simplifying how different signal implementations are integrated. It also introduces a shared test suite and specific implementations for Angular and Preact signals. The review feedback identifies that the effect implementations for both Angular and Preact do not correctly handle the cleanup callback and recommends adding comprehensive test coverage for the effect function within the shared testing utility.

Comment on lines +46 to +52
effect: (fn: () => void, cleanupCallback: () => void) => {
const e = effect(cleanupRegisterFn => {
cleanupRegisterFn(cleanupCallback);
fn();
});
return () => e.destroy();
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The effect implementation doesn't correctly match the FrameworkSignal interface. The cleanupCallback is defined as optional in the interface (cleanupCallback?: () => void), but it's required in this implementation. This will cause a type error and runtime issues if effect is called without a cleanup callback.

Please update the implementation to handle an optional cleanupCallback.

  effect: (fn: () => void, cleanupCallback?: () => void) => {
    const e = effect(cleanupRegisterFn => {
      if (cleanupCallback) {
        cleanupRegisterFn(cleanupCallback);
      }
      fn();
    });
    return () => e.destroy();
  },

wrap: <T>(val: T) => new PSignal(val),
unwrap: <T>(val: PSignal<T>) => val.value,
set: <T>(sig: PSignal<T>, value: T) => (sig.value = value),
effect: (fn: () => void) => effect(fn),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The current implementation of effect for Preact signals doesn't support the cleanupCallback from the FrameworkSignal interface. It completely ignores the second argument.

The effect function from @preact/signals-core supports a cleanup mechanism by allowing the function passed to it to return a cleanup function. You should adapt the implementation to use this feature.

  effect: (fn: () => void, cleanupCallback?: () => void) => effect(() => {
    fn();
    return cleanupCallback;
  })

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@ava-cassiopeia what do you think about this feedback? How picky should we be with these test implementations? (I guess people may copy-paste them and then encounter bugs?)

it('rejects a non-signal', () => {
assert.strictEqual(SignalImpl.isSignal('hello'), false);
});
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The shared tests for FrameworkSignal implementations are missing coverage for the effect function. According to the repository's style guide, code changes should have tests. Since the effect function is a key part of the FrameworkSignal interface, it should be tested to ensure all implementations behave correctly, especially regarding cleanup logic.

I suggest adding a test case for effect.

    });

    it('handles effects and cleanup', () => {
      const signal = SignalImpl.wrap(0);
      let effectCount = 0;
      let cleanupCount = 0;

      const stopEffect = SignalImpl.effect(
        () => {
          SignalImpl.unwrap(signal); // depend on signal
          effectCount++;
        },
        () => {
          cleanupCount++;
        }
      );

      assert.strictEqual(effectCount, 1, 'effect should run once on creation');
      assert.strictEqual(cleanupCount, 0, 'cleanup should not run on creation');

      SignalImpl.set(signal, 1);
      // Effect should run again, and cleanup should run once before it.
      assert.strictEqual(effectCount, 2, 'effect should re-run on dependency change');
      assert.strictEqual(cleanupCount, 1, 'cleanup should run before effect re-run');

      stopEffect();
      // Cleanup should run on stop.
      assert.strictEqual(cleanupCount, 2, 'cleanup should run on stop');

      SignalImpl.set(signal, 2);
      // Effect and cleanup should not run after stopping.
      assert.strictEqual(effectCount, 2, 'effect should not run after stop');
      assert.strictEqual(cleanupCount, 2, 'cleanup should not run after stop');
    });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for putting this alternative together - do you think we should present this to the core a2ui team and see what they think?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

If you're dialing in today, let's bring it up? I'm not sure who else is going to have opinions about this other than us? Maybe @gspencergoog?

// type collision. Normally, the AngularSignal would `satisfies FrameworkSignal`,
// and the declaration of SignalKinds wouldn't need to suppress anything.

runFrameworkSignalTests('Preact implementation', PreactSignal);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm strongly against code reuse in tests like this, as it actively buries the lede in tests, following the philosophy in go/tott/643.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, I know, I just did this to "prove" (by the test construction) that both implementations behaved the same. Since the specific implementations themselves (preact and angular) are not that important, and the testing logic is not that big, I guess we could just duplicate the test cases on each file; I don't mind!

Copy link
Copy Markdown
Collaborator Author

@ditman ditman left a comment

Choose a reason for hiding this comment

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

Unfortunately, there's a usability snag in this solution, so we're going with @ava-cassiopeia's!

Comment on lines +34 to +44
/**
* A generic representation of a read-only Signal.
* Resolves to the specific framework's signal type if augmented.
*/
export type Signal<T> = SignalKinds<T> extends { readonly: infer R } ? R : unknown;

/**
* A generic representation of a writable Signal.
* Resolves to the specific framework's signal type if augmented.
*/
export type WritableSignal<T> = SignalKinds<T> extends { writable: infer W } ? W : unknown;
Copy link
Copy Markdown
Collaborator Author

@ditman ditman Mar 31, 2026

Choose a reason for hiding this comment

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

This is a usability problem. The way these two types are inferred is by extracting the "readonly" and "writable" properties from the SignalKinds that the user is defining.

HOWEVER, the SignalKinds type can't enforce that users define them with readonly and writable; users can do:

declare module './signals.js' {
  interface SignalKinds<T> {
    foo: PSignal<T>;
    bar: PSignal<T>;
  }
}

The type system won't flag the SignalKinds definition as invalid; instead this will fail silently and upon using the signals all the types will be unknown (this is bad). (Imagine this with a less obvious typo!).

@ditman ditman closed this Mar 31, 2026
@github-project-automation github-project-automation bot moved this from Todo to Done in A2UI Mar 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants