Skip to content

Commit 9d7421c

Browse files
committed
feat: add atoms
1 parent c66e914 commit 9d7421c

23 files changed

Lines changed: 713 additions & 25 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-06-17
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
## Context
2+
3+
Currently `MapsConfig.apiKey` is set once at container creation: `props.apiKeyExp?.value ?? props.apiKey`. Since `apiKeyExp` is a `DynamicValue<string>`, its `.value` can be `undefined` on the first render and resolve later. The static snapshot misses this.
4+
5+
The datagrid widget uses `ComputedAtom<T>` (from `@mendix/widget-plugin-mobx-kit`) for reactive derived values in the DI container. Pattern: a function that returns `computed(() => ...)`, registered as a constant binding.
6+
7+
## Goals / Non-Goals
8+
9+
**Goals:**
10+
11+
- API key resolved reactively from `mainGate.props`
12+
- Priority: `apiKeyExp?.value` > `apiKey` > `null`
13+
- Once a non-null value is observed, it's cached permanently
14+
- Atom registered in DI container via a token, consumed by services
15+
16+
**Non-Goals:**
17+
18+
- Changing how the key is used downstream (geocoding, tile layers still receive `string | undefined`)
19+
- Making `geodecodeApiKey` an atom (separate concern, can follow same pattern later)
20+
21+
## Decisions
22+
23+
**1. Use `ComputedAtom<string | null>` with closure-based caching**
24+
25+
A plain closure variable caches the first non-null result. Once set, the computed short-circuits without accessing `gate.props`, so MobX drops the dependency and the atom never re-evaluates.
26+
27+
```ts
28+
function apiKeyAtom(gate: DerivedPropsGate<MapsContainerProps>): ComputedAtom<string | null> {
29+
let cached: string | null = null;
30+
return computed(() => {
31+
if (cached !== null) return cached;
32+
const value = (gate.props.apiKeyExp?.value ?? gate.props.apiKey) || null;
33+
if (value) cached = value;
34+
return value;
35+
});
36+
}
37+
```
38+
39+
Alternative considered: `observable.box` + `runInAction`. Rejected — unnecessary complexity; a plain variable achieves the same "cache forever" behavior because MobX naturally stops tracking deps that aren't read.
40+
41+
**2. Register as `CORE.apiKey` token**
42+
43+
Add `apiKey: token<ComputedAtom<string | null>>(label("apiKey"))` to `CORE_TOKENS`. Bind in container init phase since it depends on `mainGate`.
44+
45+
**3. Remove `apiKey` from `MapsConfig`**
46+
47+
The static config no longer holds the key. `MapsConfig` keeps `id`, `name`, `showCurrentLocation`.
48+
49+
**4. Update consumers**
50+
51+
- `LocationResolverService.apiKey` computed → reads from injected atom `.get()`
52+
- `MapsWidget.tsx` `mapsToken` prop → reads from atom via hook or passes through from LocationResolver (depends on whether view needs it directly)
53+
54+
## Risks / Trade-offs
55+
56+
- **[Closure mutation inside computed]** → Writing to a plain variable inside a computed is safe because MobX only tracks observable reads, not plain variable writes. The write is idempotent (set once, never again).
57+
- **[Null initial state]** → Downstream consumers must handle `null`. The tile layer and geocoding already handle undefined keys gracefully (no-op until key arrives).
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
## Why
2+
3+
The `apiKey` is currently stored as a static field in `MapsConfig`, snapshot at container creation time. Since `apiKeyExp` is a `DynamicValue<string>` that may not be resolved on first render, the config can lock in `undefined` and miss the actual key. The key needs to be a reactive computed atom that resolves lazily and caches once available.
4+
5+
## What Changes
6+
7+
- Remove `apiKey` from `MapsConfig` (static config object)
8+
- Create an `apiKeyAtom` as a `ComputedAtom<string | null>` registered in the DI container
9+
- The atom prioritizes `apiKeyExp?.value`, falls back to `apiKey` (static), returns `null` when neither is available
10+
- Once a non-null value is observed, the atom caches it permanently (never reverts to null)
11+
- Update `LocationResolverService` to consume the atom instead of reading `mainGate.props` directly for the API key
12+
13+
## Capabilities
14+
15+
### New Capabilities
16+
17+
- `api-key-atom`: Reactive, cached API key resolution via a MobX computed atom in the Maps DI container
18+
19+
### Modified Capabilities
20+
21+
_(none)_
22+
23+
## Impact
24+
25+
- `src/model/configs/Maps.config.ts` — remove `apiKey` field
26+
- `src/model/tokens.ts` — add token for apiKey atom
27+
- `src/model/containers/Maps.container.ts` — bind the atom
28+
- `src/model/services/LocationResolver.service.ts` — use atom instead of `mainGate.props` for apiKey
29+
- `src/components/MapsWidget.tsx` — remove `mapsToken` prop derivation (now handled by atom)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
## ADDED Requirements
2+
3+
### Requirement: API key resolved via computed atom
4+
5+
The Maps container SHALL provide a `ComputedAtom<string | null>` that reactively resolves the API key from widget props.
6+
7+
#### Scenario: Expression value takes priority
8+
9+
- **WHEN** `apiKeyExp.value` is a non-empty string
10+
- **THEN** the atom returns that value
11+
12+
#### Scenario: Falls back to static apiKey
13+
14+
- **WHEN** `apiKeyExp.value` is undefined or empty
15+
- **AND** `apiKey` is a non-empty string
16+
- **THEN** the atom returns the static `apiKey` value
17+
18+
#### Scenario: Returns null when no key available
19+
20+
- **WHEN** both `apiKeyExp.value` and `apiKey` are empty or undefined
21+
- **THEN** the atom returns `null`
22+
23+
### Requirement: API key cached once resolved
24+
25+
Once the atom returns a non-null value, it SHALL cache that value permanently and never revert to `null`.
26+
27+
#### Scenario: Key remains after expression becomes unavailable
28+
29+
- **WHEN** the atom has previously returned a non-null value
30+
- **AND** `apiKeyExp.value` subsequently becomes undefined
31+
- **THEN** the atom still returns the previously cached value
32+
33+
### Requirement: API key atom registered in DI container
34+
35+
The atom SHALL be registered as a `CORE_TOKENS.apiKey` token in the Maps container and injectable into services.
36+
37+
#### Scenario: LocationResolverService uses atom
38+
39+
- **WHEN** `LocationResolverService` needs the API key for geocoding
40+
- **THEN** it reads from the injected `ComputedAtom<string | null>` via `.get()`
41+
42+
### Requirement: Geodecode API key resolved via computed atom
43+
44+
The Maps container SHALL provide a `ComputedAtom<string | null>` that reactively resolves the geodecode API key from widget props, following the same pattern as the main API key atom.
45+
46+
#### Scenario: Expression value takes priority
47+
48+
- **WHEN** `geodecodeApiKeyExp.value` is a non-empty string
49+
- **THEN** the atom returns that value
50+
51+
#### Scenario: Falls back to static geodecodeApiKey
52+
53+
- **WHEN** `geodecodeApiKeyExp.value` is undefined or empty
54+
- **AND** `geodecodeApiKey` is a non-empty string
55+
- **THEN** the atom returns the static `geodecodeApiKey` value
56+
57+
#### Scenario: Returns null when no key available
58+
59+
- **WHEN** both `geodecodeApiKeyExp.value` and `geodecodeApiKey` are empty or undefined
60+
- **THEN** the atom returns `null`
61+
62+
### Requirement: Geodecode API key cached once resolved
63+
64+
Once the geodecode atom returns a non-null value, it SHALL cache that value permanently and never revert to `null`.
65+
66+
#### Scenario: Key remains after expression becomes unavailable
67+
68+
- **WHEN** the atom has previously returned a non-null value
69+
- **AND** `geodecodeApiKeyExp.value` subsequently becomes undefined
70+
- **THEN** the atom still returns the previously cached value
71+
72+
### Requirement: apiKey and geodecodeApiKey removed from MapsConfig
73+
74+
The static `MapsConfig` interface SHALL NOT contain `apiKey` or `geodecodeApiKey` fields. Both keys are resolved reactively via atoms.
75+
76+
#### Scenario: MapsConfig only contains static fields
77+
78+
- **WHEN** `mapsConfig()` is called
79+
- **THEN** the returned object contains `id`, `name`, and `showCurrentLocation` only
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
## 1. Create the key atoms
2+
3+
- [ ] 1.1 Create `src/model/atoms/apiKey.atom.ts` with `apiKeyAtom` function that returns `ComputedAtom<string | null>` with caching logic
4+
- [ ] 1.2 Create `src/model/atoms/geodecodeApiKey.atom.ts` with `geodecodeApiKeyAtom` function (same pattern, reads `geodecodeApiKeyExp?.value ?? geodecodeApiKey`)
5+
- [ ] 1.3 Add `apiKey: token<ComputedAtom<string | null>>` and `geodecodeApiKey: token<ComputedAtom<string | null>>` to `CORE_TOKENS` in `src/model/tokens.ts`
6+
7+
## 2. Update MapsConfig
8+
9+
- [ ] 2.1 Remove `apiKey` field from `MapsConfig` interface and `mapsConfig()` function
10+
- [ ] 2.2 Update `createMapsContainer.ts` if it references config.apiKey
11+
12+
## 3. Wire atoms in container
13+
14+
- [ ] 3.1 Bind both atoms in `Maps.container.ts` init phase (need mainGate): `CORE.apiKey` and `CORE.geodecodeApiKey`
15+
16+
## 4. Update consumers
17+
18+
- [ ] 4.1 Update `LocationResolverService` to inject `ComputedAtom<string | null>` for geodecodeApiKey instead of reading `mainGate.props`
19+
- [ ] 4.2 Update `MapsWidget.tsx` — derive `mapsToken` from the apiKey atom (or remove if LeafletMap/GoogleMap will read from atom directly)
20+
21+
## 5. Tests
22+
23+
- [ ] 5.1 Add unit test for `apiKeyAtom`: priority, fallback, null, and caching behavior
24+
- [ ] 5.2 Add unit test for `geodecodeApiKeyAtom`: same scenarios
25+
- [ ] 5.3 Update `LocationResolver` tests to inject atom mock instead of relying on gate props for apiKey
26+
- [ ] 5.4 Run full test suite and fix any failures
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
## Context
2+
3+
Currently `MapsConfig.apiKey` is set once at container creation: `props.apiKeyExp?.value ?? props.apiKey`. Since `apiKeyExp` is a `DynamicValue<string>`, its `.value` can be `undefined` on the first render and resolve later. The static snapshot misses this.
4+
5+
The datagrid widget uses `ComputedAtom<T>` (from `@mendix/widget-plugin-mobx-kit`) for reactive derived values in the DI container. Pattern: a function that returns `computed(() => ...)`, registered as a constant binding.
6+
7+
## Goals / Non-Goals
8+
9+
**Goals:**
10+
11+
- API key resolved reactively from `mainGate.props`
12+
- Priority: `apiKeyExp?.value` > `apiKey` > `null`
13+
- Once a non-null value is observed, it's cached permanently
14+
- Atom registered in DI container via a token, consumed by services
15+
16+
**Non-Goals:**
17+
18+
- Changing how the key is used downstream (geocoding, tile layers still receive `string | undefined`)
19+
- Making `geodecodeApiKey` an atom (separate concern, can follow same pattern later)
20+
21+
## Decisions
22+
23+
**1. Use `ComputedAtom<string | null>` with closure-based caching**
24+
25+
A plain closure variable caches the first non-null result. Once set, the computed short-circuits without accessing `gate.props`, so MobX drops the dependency and the atom never re-evaluates.
26+
27+
```ts
28+
function apiKeyAtom(gate: DerivedPropsGate<MapsContainerProps>): ComputedAtom<string | null> {
29+
let cached: string | null = null;
30+
return computed(() => {
31+
if (cached !== null) return cached;
32+
const value = (gate.props.apiKeyExp?.value ?? gate.props.apiKey) || null;
33+
if (value) cached = value;
34+
return value;
35+
});
36+
}
37+
```
38+
39+
Alternative considered: `observable.box` + `runInAction`. Rejected — unnecessary complexity; a plain variable achieves the same "cache forever" behavior because MobX naturally stops tracking deps that aren't read.
40+
41+
**2. Register as `CORE.apiKey` token**
42+
43+
Add `apiKey: token<ComputedAtom<string | null>>(label("apiKey"))` to `CORE_TOKENS`. Bind in container init phase since it depends on `mainGate`.
44+
45+
**3. Remove `apiKey` from `MapsConfig`**
46+
47+
The static config no longer holds the key. `MapsConfig` keeps `id`, `name`, `showCurrentLocation`.
48+
49+
**4. Update consumers**
50+
51+
- `LocationResolverService.apiKey` computed → reads from injected atom `.get()`
52+
- `MapsWidget.tsx` `mapsToken` prop → reads from atom via hook or passes through from LocationResolver (depends on whether view needs it directly)
53+
54+
## Risks / Trade-offs
55+
56+
- **[Closure mutation inside computed]** → Writing to a plain variable inside a computed is safe because MobX only tracks observable reads, not plain variable writes. The write is idempotent (set once, never again).
57+
- **[Null initial state]** → Downstream consumers must handle `null`. The tile layer and geocoding already handle undefined keys gracefully (no-op until key arrives).
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
## Why
2+
3+
The `apiKey` is currently stored as a static field in `MapsConfig`, snapshot at container creation time. Since `apiKeyExp` is a `DynamicValue<string>` that may not be resolved on first render, the config can lock in `undefined` and miss the actual key. The key needs to be a reactive computed atom that resolves lazily and caches once available.
4+
5+
## What Changes
6+
7+
- Remove `apiKey` from `MapsConfig` (static config object)
8+
- Create an `apiKeyAtom` as a `ComputedAtom<string | null>` registered in the DI container
9+
- The atom prioritizes `apiKeyExp?.value`, falls back to `apiKey` (static), returns `null` when neither is available
10+
- Once a non-null value is observed, the atom caches it permanently (never reverts to null)
11+
- Update `LocationResolverService` to consume the atom instead of reading `mainGate.props` directly for the API key
12+
13+
## Capabilities
14+
15+
### New Capabilities
16+
17+
- `api-key-atom`: Reactive, cached API key resolution via a MobX computed atom in the Maps DI container
18+
19+
### Modified Capabilities
20+
21+
_(none)_
22+
23+
## Impact
24+
25+
- `src/model/configs/Maps.config.ts` — remove `apiKey` field
26+
- `src/model/tokens.ts` — add token for apiKey atom
27+
- `src/model/containers/Maps.container.ts` — bind the atom
28+
- `src/model/services/LocationResolver.service.ts` — use atom instead of `mainGate.props` for apiKey
29+
- `src/components/MapsWidget.tsx` — remove `mapsToken` prop derivation (now handled by atom)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
## ADDED Requirements
2+
3+
### Requirement: API key resolved via computed atom
4+
5+
The Maps container SHALL provide a `ComputedAtom<string | null>` that reactively resolves the API key from widget props.
6+
7+
#### Scenario: Expression value takes priority
8+
9+
- **WHEN** `apiKeyExp.value` is a non-empty string
10+
- **THEN** the atom returns that value
11+
12+
#### Scenario: Falls back to static apiKey
13+
14+
- **WHEN** `apiKeyExp.value` is undefined or empty
15+
- **AND** `apiKey` is a non-empty string
16+
- **THEN** the atom returns the static `apiKey` value
17+
18+
#### Scenario: Returns null when no key available
19+
20+
- **WHEN** both `apiKeyExp.value` and `apiKey` are empty or undefined
21+
- **THEN** the atom returns `null`
22+
23+
### Requirement: API key cached once resolved
24+
25+
Once the atom returns a non-null value, it SHALL cache that value permanently and never revert to `null`.
26+
27+
#### Scenario: Key remains after expression becomes unavailable
28+
29+
- **WHEN** the atom has previously returned a non-null value
30+
- **AND** `apiKeyExp.value` subsequently becomes undefined
31+
- **THEN** the atom still returns the previously cached value
32+
33+
### Requirement: API key atom registered in DI container
34+
35+
The atom SHALL be registered as a `CORE_TOKENS.apiKey` token in the Maps container and injectable into services.
36+
37+
#### Scenario: LocationResolverService uses atom
38+
39+
- **WHEN** `LocationResolverService` needs the API key for geocoding
40+
- **THEN** it reads from the injected `ComputedAtom<string | null>` via `.get()`
41+
42+
### Requirement: Geodecode API key resolved via computed atom
43+
44+
The Maps container SHALL provide a `ComputedAtom<string | null>` that reactively resolves the geodecode API key from widget props, following the same pattern as the main API key atom.
45+
46+
#### Scenario: Expression value takes priority
47+
48+
- **WHEN** `geodecodeApiKeyExp.value` is a non-empty string
49+
- **THEN** the atom returns that value
50+
51+
#### Scenario: Falls back to static geodecodeApiKey
52+
53+
- **WHEN** `geodecodeApiKeyExp.value` is undefined or empty
54+
- **AND** `geodecodeApiKey` is a non-empty string
55+
- **THEN** the atom returns the static `geodecodeApiKey` value
56+
57+
#### Scenario: Returns null when no key available
58+
59+
- **WHEN** both `geodecodeApiKeyExp.value` and `geodecodeApiKey` are empty or undefined
60+
- **THEN** the atom returns `null`
61+
62+
### Requirement: Geodecode API key cached once resolved
63+
64+
Once the geodecode atom returns a non-null value, it SHALL cache that value permanently and never revert to `null`.
65+
66+
#### Scenario: Key remains after expression becomes unavailable
67+
68+
- **WHEN** the atom has previously returned a non-null value
69+
- **AND** `geodecodeApiKeyExp.value` subsequently becomes undefined
70+
- **THEN** the atom still returns the previously cached value
71+
72+
### Requirement: apiKey and geodecodeApiKey removed from MapsConfig
73+
74+
The static `MapsConfig` interface SHALL NOT contain `apiKey` or `geodecodeApiKey` fields. Both keys are resolved reactively via atoms.
75+
76+
#### Scenario: MapsConfig only contains static fields
77+
78+
- **WHEN** `mapsConfig()` is called
79+
- **THEN** the returned object contains `id`, `name`, and `showCurrentLocation` only
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
## 1. Create the key atoms
2+
3+
- [x] 1.1 Create `src/model/atoms/apiKey.atom.ts` with `apiKeyAtom` function that returns `ComputedAtom<string | null>` with caching logic
4+
- [x] 1.2 Create `src/model/atoms/geodecodeApiKey.atom.ts` with `geodecodeApiKeyAtom` function (same pattern, reads `geodecodeApiKeyExp?.value ?? geodecodeApiKey`)
5+
- [x] 1.3 Add `apiKey: token<ComputedAtom<string | null>>` and `geodecodeApiKey: token<ComputedAtom<string | null>>` to `CORE_TOKENS` in `src/model/tokens.ts`
6+
7+
## 2. Update MapsConfig
8+
9+
- [x] 2.1 Remove `apiKey` field from `MapsConfig` interface and `mapsConfig()` function
10+
- [x] 2.2 Update `createMapsContainer.ts` if it references config.apiKey
11+
12+
## 3. Wire atoms in container
13+
14+
- [x] 3.1 Bind both atoms in `Maps.container.ts` init phase (need mainGate): `CORE.apiKey` and `CORE.geodecodeApiKey`
15+
16+
## 4. Update consumers
17+
18+
- [x] 4.1 Update `LocationResolverService` to inject `ComputedAtom<string | null>` for geodecodeApiKey instead of reading `mainGate.props`
19+
- [x] 4.2 Update `MapsWidget.tsx` — derive `mapsToken` from the apiKey atom (or remove if LeafletMap/GoogleMap will read from atom directly)
20+
21+
## 5. Tests
22+
23+
- [x] 5.1 Add unit test for `apiKeyAtom`: priority, fallback, null, and caching behavior
24+
- [x] 5.2 Add unit test for `geodecodeApiKeyAtom`: same scenarios
25+
- [x] 5.3 Update `LocationResolver` tests to inject atom mock instead of relying on gate props for apiKey
26+
- [x] 5.4 Run full test suite and fix any failures

0 commit comments

Comments
 (0)