@playfast/reform 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -6
- package/dist/calc/asyncCalc.d.ts +3 -1
- package/dist/calc/asyncCalc.d.ts.map +1 -1
- package/dist/calc/asyncCalc.js +15 -5
- package/dist/calc/asyncCalc.js.map +1 -1
- package/dist/calc/asyncCalcTypes.d.ts +2 -0
- package/dist/calc/asyncCalcTypes.d.ts.map +1 -1
- package/dist/calc/calcFamily.d.ts.map +1 -1
- package/dist/calc/calcFamily.js +77 -34
- package/dist/calc/calcFamily.js.map +1 -1
- package/dist/calc/queryState.d.ts +3 -1
- package/dist/calc/queryState.d.ts.map +1 -1
- package/dist/calc/queryState.js +31 -9
- package/dist/calc/queryState.js.map +1 -1
- package/dist/channel/channel.d.ts.map +1 -1
- package/dist/channel/channel.js +6 -1
- package/dist/channel/channel.js.map +1 -1
- package/dist/compose/composition.d.ts.map +1 -1
- package/dist/compose/composition.js +7 -1
- package/dist/compose/composition.js.map +1 -1
- package/dist/compose/structure.d.ts.map +1 -1
- package/dist/compose/structure.js +33 -8
- package/dist/compose/structure.js.map +1 -1
- package/dist/compose/ui.d.ts.map +1 -1
- package/dist/compose/ui.js +16 -5
- package/dist/compose/ui.js.map +1 -1
- package/dist/event/event.d.ts.map +1 -1
- package/dist/event/event.js +23 -2
- package/dist/event/event.js.map +1 -1
- package/dist/internal/familyKeys.d.ts +16 -0
- package/dist/internal/familyKeys.d.ts.map +1 -0
- package/dist/internal/familyKeys.js +35 -0
- package/dist/internal/familyKeys.js.map +1 -0
- package/dist/internal/platform.d.ts +43 -0
- package/dist/internal/platform.d.ts.map +1 -0
- package/dist/internal/platform.js +68 -0
- package/dist/internal/platform.js.map +1 -0
- package/dist/internal/queryDriver.d.ts.map +1 -1
- package/dist/internal/queryDriver.js +88 -64
- package/dist/internal/queryDriver.js.map +1 -1
- package/dist/internal/queryDriverConsume.d.ts +9 -0
- package/dist/internal/queryDriverConsume.d.ts.map +1 -0
- package/dist/internal/queryDriverConsume.js +16 -0
- package/dist/internal/queryDriverConsume.js.map +1 -0
- package/dist/internal/queryDriverHydrate.d.ts.map +1 -1
- package/dist/internal/queryDriverHydrate.js +9 -3
- package/dist/internal/queryDriverHydrate.js.map +1 -1
- package/dist/internal/queryDriverPersist.d.ts +16 -0
- package/dist/internal/queryDriverPersist.d.ts.map +1 -0
- package/dist/internal/queryDriverPersist.js +15 -0
- package/dist/internal/queryDriverPersist.js.map +1 -0
- package/dist/internal/queryDriverPersistWarn.d.ts +12 -0
- package/dist/internal/queryDriverPersistWarn.d.ts.map +1 -0
- package/dist/internal/queryDriverPersistWarn.js +31 -0
- package/dist/internal/queryDriverPersistWarn.js.map +1 -0
- package/dist/internal/queryDriverSettle.d.ts +23 -0
- package/dist/internal/queryDriverSettle.d.ts.map +1 -0
- package/dist/internal/queryDriverSettle.js +52 -0
- package/dist/internal/queryDriverSettle.js.map +1 -0
- package/dist/internal/queryDriverStore.d.ts +2 -28
- package/dist/internal/queryDriverStore.d.ts.map +1 -1
- package/dist/internal/queryDriverStore.js +5 -29
- package/dist/internal/queryDriverStore.js.map +1 -1
- package/dist/internal/queryDriverTypes.d.ts +3 -0
- package/dist/internal/queryDriverTypes.d.ts.map +1 -1
- package/dist/internal/scheduler.d.ts +1 -0
- package/dist/internal/scheduler.d.ts.map +1 -1
- package/dist/internal/scheduler.js +69 -6
- package/dist/internal/scheduler.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/reducer/reducer.js +3 -3
- package/dist/reducer/reducer.js.map +1 -1
- package/dist/remote/pendingQueue.d.ts +4 -2
- package/dist/remote/pendingQueue.d.ts.map +1 -1
- package/dist/remote/pendingQueue.js +20 -4
- package/dist/remote/pendingQueue.js.map +1 -1
- package/dist/remote/remoteState.d.ts.map +1 -1
- package/dist/remote/remoteState.js +27 -14
- package/dist/remote/remoteState.js.map +1 -1
- package/dist/remote/remoteStateSend.d.ts +3 -1
- package/dist/remote/remoteStateSend.d.ts.map +1 -1
- package/dist/remote/remoteStateSend.js +19 -3
- package/dist/remote/remoteStateSend.js.map +1 -1
- package/dist/remote/remoteStateTypes.d.ts +3 -0
- package/dist/remote/remoteStateTypes.d.ts.map +1 -1
- package/dist/runtime/bus.d.ts +5 -0
- package/dist/runtime/bus.d.ts.map +1 -1
- package/dist/runtime/bus.js +1 -1
- package/dist/runtime/bus.js.map +1 -1
- package/dist/runtime/capturedAppRuntime.d.ts.map +1 -1
- package/dist/runtime/capturedAppRuntime.js +37 -51
- package/dist/runtime/capturedAppRuntime.js.map +1 -1
- package/dist/runtime/eventBudget.d.ts.map +1 -1
- package/dist/runtime/eventBudget.js +2 -1
- package/dist/runtime/eventBudget.js.map +1 -1
- package/dist/runtime/featureMount.d.ts +34 -2
- package/dist/runtime/featureMount.d.ts.map +1 -1
- package/dist/runtime/featureMount.js +66 -2
- package/dist/runtime/featureMount.js.map +1 -1
- package/dist/runtime/loop.d.ts +12 -0
- package/dist/runtime/loop.d.ts.map +1 -1
- package/dist/runtime/loop.js +8 -5
- package/dist/runtime/loop.js.map +1 -1
- package/dist/state/state.d.ts +2 -2
- package/dist/state/state.d.ts.map +1 -1
- package/dist/state/state.js +2 -2
- package/dist/state/state.js.map +1 -1
- package/dist/state/stateFamily.d.ts +2 -2
- package/dist/state/stateFamily.d.ts.map +1 -1
- package/dist/state/stateFamily.js +80 -32
- package/dist/state/stateFamily.js.map +1 -1
- package/dist/state/stateGroup.d.ts +5 -5
- package/dist/state/stateGroup.d.ts.map +1 -1
- package/dist/state/stateGroup.js +1 -1
- package/dist/state/stateGroup.js.map +1 -1
- package/dist/testkit/flight.testkit.d.ts.map +1 -1
- package/dist/testkit/flight.testkit.js +2 -1
- package/dist/testkit/flight.testkit.js.map +1 -1
- package/dist/wire/tree.d.ts.map +1 -1
- package/dist/wire/tree.js +6 -1
- package/dist/wire/tree.js.map +1 -1
- package/package.json +1 -1
- package/src/calc/asyncCalc-key-attribution.test.ts +182 -0
- package/src/calc/asyncCalc-registry-name.test.ts +82 -0
- package/src/calc/asyncCalc.superseded-commit.test.ts +162 -0
- package/src/calc/asyncCalc.test.ts +35 -7
- package/src/calc/asyncCalc.ts +33 -10
- package/src/calc/asyncCalcTypes.ts +6 -0
- package/src/calc/calcFamily.ts +81 -34
- package/src/calc/family-identity.test.ts +231 -0
- package/src/calc/queryState.ts +36 -5
- package/src/channel/channel-throttle-frames.test.ts +163 -0
- package/src/channel/channel.ts +5 -0
- package/src/compose/compose-identity.test.ts +175 -0
- package/src/compose/composition.ts +8 -4
- package/src/compose/structure.ts +45 -13
- package/src/compose/ui.ts +22 -5
- package/src/event/event.ts +31 -2
- package/src/event/syncTrigger.test.ts +66 -0
- package/src/internal/familyKeys.ts +41 -0
- package/src/internal/platform.test.ts +55 -0
- package/src/internal/platform.ts +72 -0
- package/src/internal/queryDriver-defect-attribution.test.ts +107 -0
- package/src/internal/queryDriver-persist-key.test.ts +118 -0
- package/src/internal/queryDriver.persistence-staleness.test.ts +209 -0
- package/src/internal/queryDriver.ts +92 -80
- package/src/internal/queryDriverConsume.ts +42 -0
- package/src/internal/queryDriverHydrate.ts +9 -3
- package/src/internal/queryDriverPersist.ts +42 -0
- package/src/internal/queryDriverPersistWarn.ts +61 -0
- package/src/internal/queryDriverSettle.ts +85 -0
- package/src/internal/queryDriverStore.ts +9 -74
- package/src/internal/queryDriverTypes.ts +10 -0
- package/src/internal/queryState-and-scheduler-audit.test.ts +170 -0
- package/src/internal/scheduler-multi-source-wake.test.ts +111 -0
- package/src/internal/scheduler.ts +80 -6
- package/src/internal.ts +1 -0
- package/src/reducer/reducer.ts +3 -3
- package/src/remote/pendingQueue.ts +35 -5
- package/src/remote/remoteState.intent-key-stamp.test.ts +207 -0
- package/src/remote/remoteState.key-attribution.test.ts +174 -0
- package/src/remote/remoteState.ts +27 -15
- package/src/remote/remoteStateSend.ts +29 -8
- package/src/remote/remoteStateTypes.ts +7 -0
- package/src/runtime/bus.ts +6 -1
- package/src/runtime/capturedAppRuntime.ts +134 -110
- package/src/runtime/eventBudget.ts +2 -1
- package/src/runtime/feature-mount-ownership.test.ts +539 -0
- package/src/runtime/featureMount.ts +110 -3
- package/src/runtime/loop.ts +29 -6
- package/src/runtime/mount-inflight-ownership.test.ts +361 -0
- package/src/runtime/mount-scope-hardening.test.ts +440 -0
- package/src/runtime/runtime.schema-identity.test.ts +188 -0
- package/src/state/state.ts +8 -4
- package/src/state/stateFamily.ts +86 -36
- package/src/state/stateGroup.ts +24 -6
- package/src/testkit/flight.testkit.ts +2 -1
- package/src/wire/tree-key-presence.test.ts +46 -0
- package/src/wire/tree.ts +9 -1
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Data, Effect, Layer, Option, Schema as S } from 'effect'
|
|
3
|
+
import {
|
|
4
|
+
absent,
|
|
5
|
+
AsyncCalc,
|
|
6
|
+
Boundary,
|
|
7
|
+
Composition,
|
|
8
|
+
each,
|
|
9
|
+
mount,
|
|
10
|
+
provide,
|
|
11
|
+
slot,
|
|
12
|
+
State,
|
|
13
|
+
StateGroup,
|
|
14
|
+
structureEquals,
|
|
15
|
+
Ui,
|
|
16
|
+
ui,
|
|
17
|
+
} from '../index'
|
|
18
|
+
import type { Contract } from './ui'
|
|
19
|
+
import { makeFlightGate, until } from '../testkit/flight.testkit'
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// 1. compose/composition.ts:78 — `parseProps` is built with `decodeUnknownOption`,
|
|
23
|
+
// so it parses the schema's ENCODED side. But `PropsOf<Config>` (and therefore
|
|
24
|
+
// `CompositionClass['Props']`, `RenderEnv.props`, `SlotProps`, and every
|
|
25
|
+
// `one(...)`/`each(...)` fill) is the schema's TYPE side, and every call site
|
|
26
|
+
// (react/compose.ts:198, proof/engineSink.ts:137, reform-remote/wireRender.ts:63,
|
|
27
|
+
// feature/featureFactory.ts:62) hands it an already-decoded Type value. This is
|
|
28
|
+
// the same defect the package already fixed for `State` (state.ts:105, "check the
|
|
29
|
+
// Type side — decodeUnknownSync would reject transforming schemas") and `Event`
|
|
30
|
+
// (event.ts:53); the props path was missed. A composition whose props schema is
|
|
31
|
+
// not an identity transform can never render.
|
|
32
|
+
|
|
33
|
+
const propsSchema = S.Struct({ count: S.NumberFromString })
|
|
34
|
+
|
|
35
|
+
class StampUi extends ui('hunt.StampUi')<{ props: { readonly count: number } }>() {}
|
|
36
|
+
class Stamp extends Composition.make('hunt.Stamp', {
|
|
37
|
+
title: 'Stamp',
|
|
38
|
+
props: propsSchema,
|
|
39
|
+
ui: StampUi,
|
|
40
|
+
})<Stamp>() {}
|
|
41
|
+
|
|
42
|
+
it('composition: parseProps accepts the props value its own Props type declares', () => {
|
|
43
|
+
// The declared Props type of this composition is `{ count: number }` — this
|
|
44
|
+
// assignment is what the framework's own typing promises a parent may render with.
|
|
45
|
+
const props: typeof Stamp.Props = { count: 3 }
|
|
46
|
+
// `Schema.validateOption` — what State/Event/`makeFeatureInputParser` use for an
|
|
47
|
+
// already-decoded domain value — accepts it:
|
|
48
|
+
expect(S.validateOption(propsSchema)(props)).toEqual(Option.some({ count: 3 }))
|
|
49
|
+
// `parseProps` accepts only the ENCODED form, which no call site ever holds:
|
|
50
|
+
expect(Stamp.parseProps({ count: '3' })).toEqual(Option.some({ count: 3 }))
|
|
51
|
+
expect(Stamp.parseProps(props)).toEqual(Option.some({ count: 3 }))
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// 2. compose/ui.ts:150-151 — a Ui contract's runtime tag key is `reform/ui/${name}`
|
|
56
|
+
// with no per-definition discriminator, unlike `slot()` (slot.ts:150) and
|
|
57
|
+
// `Composition.make` (composition.ts:57) which both mint a sequenced key so that
|
|
58
|
+
// independently authored same-name definitions stay distinct. Two features that
|
|
59
|
+
// each declare `ui('Header')` silently share one provider entry: the last
|
|
60
|
+
// `provide` wins and the wrong view renders for the other contract.
|
|
61
|
+
|
|
62
|
+
it.live('ui: independently authored same-name contracts resolve their own views', () => {
|
|
63
|
+
class First extends ui('hunt.Header')<{ props: { readonly label: string } }>() {}
|
|
64
|
+
class Second extends ui('hunt.Header')<{ props: { readonly label: string } }>() {}
|
|
65
|
+
const FirstView = Ui.make(First, ({ label }) => `first:${label}`)
|
|
66
|
+
const SecondView = Ui.make(Second, ({ label }) => `second:${label}`)
|
|
67
|
+
const Live = Layer.mergeAll(provide(First, FirstView), provide(Second, SecondView))
|
|
68
|
+
return Effect.gen(function* () {
|
|
69
|
+
const first = yield* First.impl
|
|
70
|
+
const second = yield* Second.impl
|
|
71
|
+
expect(second({ label: 'x' }, {}, {})).toBe('second:x')
|
|
72
|
+
expect(first({ label: 'x' }, {}, {})).toBe('first:x')
|
|
73
|
+
}).pipe(Effect.provide(Live))
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// 3. calc/queryState.ts:37 read through boundary/boundary.ts:194 — when the inputs
|
|
78
|
+
// move, a withheld previous-key result reads Loading, so the Boundary re-pends.
|
|
79
|
+
// `keepPreviousData: true` is documented as retaining the previous key's PAYLOAD
|
|
80
|
+
// ("safe when the payload is only displayed"), but the same switch also republishes
|
|
81
|
+
// the previous key's ERROR as the new key's `Error` arm. Here nothing is being
|
|
82
|
+
// "kept" — the failed key produced no data at all — yet the Boundary reports
|
|
83
|
+
// Errored for a key that is still in flight and has never failed.
|
|
84
|
+
|
|
85
|
+
class Boom extends Data.TaggedError('hunt.Boom')<{ readonly detail: string }> {}
|
|
86
|
+
|
|
87
|
+
const keyGate = makeFlightGate()
|
|
88
|
+
Effect.runSync(keyGate.hold)
|
|
89
|
+
|
|
90
|
+
it.live('boundary: a previous key’s error must not gate the key now in flight', () => {
|
|
91
|
+
class Subject extends State.make('subject', S.Number) {}
|
|
92
|
+
class Inputs extends StateGroup.make(Subject) {}
|
|
93
|
+
class Q extends AsyncCalc.make('hunt.Q', {
|
|
94
|
+
inputs: [StateGroup.select(Inputs, 'subject')],
|
|
95
|
+
output: S.Number,
|
|
96
|
+
error: S.instanceOf(Boom),
|
|
97
|
+
alwaysOn: true,
|
|
98
|
+
}) {}
|
|
99
|
+
class Gate extends Boundary.make('hunt.Gate', { over: [Q] }) {}
|
|
100
|
+
const TestLayer = Boundary.live(Gate).pipe(
|
|
101
|
+
Layer.provideMerge(
|
|
102
|
+
AsyncCalc.live(Q, {
|
|
103
|
+
keepPreviousData: true,
|
|
104
|
+
query: ({ subject }) =>
|
|
105
|
+
subject === 1
|
|
106
|
+
? Effect.fail(new Boom({ detail: 'subject-1 failed' }))
|
|
107
|
+
: keyGate.through(Effect.succeed(subject)),
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
110
|
+
Layer.provideMerge(StateGroup.live(Inputs, { subject: 1 })),
|
|
111
|
+
)
|
|
112
|
+
return Effect.gen(function* () {
|
|
113
|
+
const subject = yield* StateGroup.select(Inputs, 'subject').store
|
|
114
|
+
const qStore = yield* Q.store
|
|
115
|
+
const store = yield* Gate.store
|
|
116
|
+
|
|
117
|
+
yield* until(
|
|
118
|
+
() => store.get()._tag,
|
|
119
|
+
(t) => t === 'Errored',
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
subject.set(2)
|
|
123
|
+
// The gate proves the query for subject 2 is parked in flight and has never settled.
|
|
124
|
+
yield* keyGate.awaitEntry(1)
|
|
125
|
+
expect(store.get()._tag).toBe('Pending')
|
|
126
|
+
// Root cause: the arm itself reports key 1's failure as key 2's state.
|
|
127
|
+
expect(qStore.get()._tag).not.toBe('Error')
|
|
128
|
+
|
|
129
|
+
yield* keyGate.release
|
|
130
|
+
yield* until(
|
|
131
|
+
() => store.get()._tag,
|
|
132
|
+
(t) => t === 'Ready',
|
|
133
|
+
)
|
|
134
|
+
}).pipe(Effect.provide(TestLayer))
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// 4. compose/structure.ts:170-186 — `deepEquals` classifies every non-array object as
|
|
139
|
+
// a bag of own enumerable keys, so any value that carries its data outside such keys
|
|
140
|
+
// (Date, Map, Set, class instances) compares EQUAL to every other value of its kind.
|
|
141
|
+
// That verdict feeds `retainEach` (structure.ts:114), which then hands back the
|
|
142
|
+
// PREVIOUS render's fill: `each` returns props holding a stale value, not merely a
|
|
143
|
+
// stale equality answer.
|
|
144
|
+
|
|
145
|
+
class StampedUi extends ui('hunt.StampedUi')<{ props: { readonly at: Date } }>() {}
|
|
146
|
+
class Stamped extends Composition.make('hunt.Stamped', {
|
|
147
|
+
title: 'Stamped',
|
|
148
|
+
ui: StampedUi,
|
|
149
|
+
})<Stamped>() {}
|
|
150
|
+
class RowSlot extends slot('Row')<RowSlot, typeof Stamped>() {}
|
|
151
|
+
class BoardUi extends ui('hunt.BoardUi')<{
|
|
152
|
+
props: { readonly at: Date }
|
|
153
|
+
slots: { Row: RowSlot }
|
|
154
|
+
}>() {}
|
|
155
|
+
type Board = Contract<typeof BoardUi>
|
|
156
|
+
|
|
157
|
+
it('each: a changed prop value must not be retained from the previous fill', () => {
|
|
158
|
+
const rows = [{ id: 'a' }]
|
|
159
|
+
const first = each(rows, {
|
|
160
|
+
key: (row) => row.id,
|
|
161
|
+
props: (row) => ({ at: new Date(1), id: row.id }),
|
|
162
|
+
})
|
|
163
|
+
const second = each(rows, {
|
|
164
|
+
key: (row) => row.id,
|
|
165
|
+
props: (row) => ({ at: new Date(2), id: row.id }),
|
|
166
|
+
})
|
|
167
|
+
expect(first.items[0]?.props.at.getTime()).toBe(1)
|
|
168
|
+
expect(second.items[0]?.props.at.getTime()).toBe(2)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('structureEquals: frames whose props differ must not compare equal', () => {
|
|
172
|
+
const left = mount<Board>({ props: { at: new Date(1) }, slots: { Row: absent } })
|
|
173
|
+
const right = mount<Board>({ props: { at: new Date(2) }, slots: { Row: absent } })
|
|
174
|
+
expect(structureEquals(left, right)).toBe(false)
|
|
175
|
+
})
|
|
@@ -50,7 +50,6 @@ export type {
|
|
|
50
50
|
SlotClassesOf,
|
|
51
51
|
} from './compositionTypes'
|
|
52
52
|
|
|
53
|
-
|
|
54
53
|
const compositionDefinitionSequence: MutableRef.MutableRef<number> = GlobalValue.globalValue(
|
|
55
54
|
Symbol.for('reform/CompositionDefinitionSequence'),
|
|
56
55
|
() => MutableRef.make(0),
|
|
@@ -75,8 +74,14 @@ export const make =
|
|
|
75
74
|
const propsSchema = Schema.make<PropsOf<Config>, EncodedPropsOf<Config>>(
|
|
76
75
|
config.props?.ast ?? Schema.Unknown.ast,
|
|
77
76
|
)
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
// Both sides. `PropsOf<Config>` — and so every value a parent renders with — is
|
|
78
|
+
// the schema's Type, which is what the in-process call sites hold; a decode-only
|
|
79
|
+
// parser rejects a transforming props schema's own declared props. Props
|
|
80
|
+
// arriving over the wire are Encoded, so that reading has to keep working too.
|
|
81
|
+
const validateProps = Schema.validateOption(propsSchema)
|
|
82
|
+
const decodeProps = Schema.decodeUnknownOption(propsSchema)
|
|
83
|
+
const parseProps = (input: unknown): Option.Option<PropsOf<Config>> =>
|
|
84
|
+
Option.orElse(validateProps(input), () => decodeProps(input))
|
|
80
85
|
const tag = Context.GenericTag<
|
|
81
86
|
CompositionId<N, Self, RequiredSlotIdentities<C>>,
|
|
82
87
|
CompositionService<PropsOf<Config>, C>
|
|
@@ -100,7 +105,6 @@ export const make =
|
|
|
100
105
|
return composition
|
|
101
106
|
}
|
|
102
107
|
|
|
103
|
-
|
|
104
108
|
export const live = <
|
|
105
109
|
P,
|
|
106
110
|
C extends UiContract,
|
package/src/compose/structure.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Option, Struct } from 'effect'
|
|
1
|
+
import { Equal, Option, Struct } from 'effect'
|
|
2
2
|
import { reuse } from '../internal/reuse'
|
|
3
3
|
import type { SlotProps } from './slot'
|
|
4
4
|
import type { EventsOf, UiContract } from './ui'
|
|
@@ -164,6 +164,29 @@ interface ValuePair {
|
|
|
164
164
|
readonly right: unknown
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
// A structural comparison is only meaningful over own enumerable keys when those
|
|
168
|
+
// keys are the whole value — true of JSON-shaped records, false of everything
|
|
169
|
+
// carrying its state anywhere else.
|
|
170
|
+
const isPlainRecord = (candidate: unknown): candidate is Record<string, unknown> => {
|
|
171
|
+
if (!isRecord(candidate)) {
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
const prototype = Object.getPrototypeOf(candidate)
|
|
175
|
+
return prototype === Object.prototype || prototype === null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const recordEntriesEqual = (
|
|
179
|
+
left: Record<string, unknown>,
|
|
180
|
+
right: Record<string, unknown>,
|
|
181
|
+
leftKeys: ReadonlyArray<string>,
|
|
182
|
+
): boolean =>
|
|
183
|
+
leftKeys.length === Struct.keys(right).length &&
|
|
184
|
+
leftKeys.every(
|
|
185
|
+
(key) =>
|
|
186
|
+
Object.prototype.hasOwnProperty.call(right, key) &&
|
|
187
|
+
deepEquals({ left: left[key], right: right[key] }),
|
|
188
|
+
)
|
|
189
|
+
|
|
167
190
|
const deepEquals = ({ left, right }: ValuePair): boolean => {
|
|
168
191
|
if (Object.is(left, right)) {
|
|
169
192
|
return true
|
|
@@ -174,19 +197,28 @@ const deepEquals = ({ left, right }: ValuePair): boolean => {
|
|
|
174
197
|
left.every((element, index) => deepEquals({ left: element, right: right[index] }))
|
|
175
198
|
)
|
|
176
199
|
}
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
deepEquals({ left: left[key], right: right[key] }),
|
|
186
|
-
)
|
|
187
|
-
)
|
|
200
|
+
if (isPlainRecord(left) && isPlainRecord(right)) {
|
|
201
|
+
return recordEntriesEqual(left, right, Struct.keys(left))
|
|
202
|
+
}
|
|
203
|
+
// Not a plain record. Own enumerable keys describe nothing about a `Date`, `Map` or
|
|
204
|
+
// `Set` — comparing by them calls every one of them empty, and so equal — so ask
|
|
205
|
+
// the value itself first.
|
|
206
|
+
if (Equal.equals(left, right)) {
|
|
207
|
+
return true
|
|
188
208
|
}
|
|
189
|
-
|
|
209
|
+
// Effect containers say no whenever they wrap a freshly allocated record, because
|
|
210
|
+
// their own equality bottoms out in reference identity. They do carry their payload
|
|
211
|
+
// in own properties, so descend: two structurally equal `Option.some({…})` are the
|
|
212
|
+
// same frame. A value with no own keys (a `Date`, a `Map`) has already answered.
|
|
213
|
+
if (!isRecord(left) || !isRecord(right)) {
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
216
|
+
const leftKeys = Struct.keys(left)
|
|
217
|
+
return (
|
|
218
|
+
leftKeys.length > 0 &&
|
|
219
|
+
Object.getPrototypeOf(left) === Object.getPrototypeOf(right) &&
|
|
220
|
+
recordEntriesEqual(left, right, leftKeys)
|
|
221
|
+
)
|
|
190
222
|
}
|
|
191
223
|
|
|
192
224
|
export const structureEquals = (
|
package/src/compose/ui.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FunctionComponent } from 'react'
|
|
2
|
-
import { Context, type Schema } from 'effect'
|
|
2
|
+
import { Context, GlobalValue, MutableRef, type Schema } from 'effect'
|
|
3
3
|
import type { AST } from 'effect/SchemaAST'
|
|
4
4
|
import { type Manifest, definitionClass } from '../definition/definition'
|
|
5
5
|
import type { Trigger } from '../ui/trigger'
|
|
@@ -139,6 +139,18 @@ type UiDefinitionStatics<
|
|
|
139
139
|
typeof UiTypeId | 'manifest' | 'impl' | 'provider' | 'capture'
|
|
140
140
|
>
|
|
141
141
|
|
|
142
|
+
// Same rationale as `slot()` and `Composition.make`: two independently authored
|
|
143
|
+
// definitions that happen to share a name are two contracts, not one. Keying the
|
|
144
|
+
// runtime tag by name alone collapses them, and the last `provide` wins silently.
|
|
145
|
+
// The wire still identifies a view by `manifest.name`, which is untouched.
|
|
146
|
+
const uiDefinitionSequence: MutableRef.MutableRef<number> = GlobalValue.globalValue(
|
|
147
|
+
Symbol.for('reform/UiDefinitionSequence'),
|
|
148
|
+
() => MutableRef.make(0),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
const nextUiTagKey = (name: string): string =>
|
|
152
|
+
`reform/ui/${name}/${MutableRef.incrementAndGet(uiDefinitionSequence)}`
|
|
153
|
+
|
|
142
154
|
const buildUi = <
|
|
143
155
|
N extends string,
|
|
144
156
|
C extends UiContract,
|
|
@@ -147,8 +159,9 @@ const buildUi = <
|
|
|
147
159
|
name: N,
|
|
148
160
|
manifest: M,
|
|
149
161
|
): UiClass<C, N> & { readonly manifest: M } => {
|
|
150
|
-
const
|
|
151
|
-
const
|
|
162
|
+
const key = nextUiTagKey(name)
|
|
163
|
+
const impl = Context.GenericTag<UiService<N>, ViewImpl<C>>(key)
|
|
164
|
+
const provider = Context.GenericTag<UiService<N>, CallableView>(key)
|
|
152
165
|
const contract = definitionClass<UiClass<C, N> & { readonly manifest: M }>({
|
|
153
166
|
[UiTypeId]: UiTypeId,
|
|
154
167
|
manifest,
|
|
@@ -213,8 +226,12 @@ export const make = <U extends AnyUi>(
|
|
|
213
226
|
contract: U,
|
|
214
227
|
view: ViewImpl<Contract<U>>,
|
|
215
228
|
): MadeView<Contract<U>, UiName<U>, U> => {
|
|
216
|
-
//
|
|
217
|
-
|
|
229
|
+
// Branded on a fresh wrapper rather than on `view` itself: the caller's function is
|
|
230
|
+
// theirs, and attaching in place lets a second `Ui.make` over the same view rebrand
|
|
231
|
+
// the first contract's binding out from under it.
|
|
232
|
+
const wrapped: ViewImpl<Contract<U>> = (props, slots, events) => view(props, slots, events)
|
|
233
|
+
// oxlint-disable-next-line reform-rules/no-object-assign -- `wrapped` is callable; spread would drop callability, so its reflection statics are attached in place
|
|
234
|
+
const made: MadeView<Contract<U>, UiName<U>, U> = Object.assign(wrapped, {
|
|
218
235
|
[UiViewContract]: contract,
|
|
219
236
|
capture: <Result>(visit: MadeViewCapture<Result>): Result => visit(made),
|
|
220
237
|
})
|
package/src/event/event.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Effect, Layer, MutableRef, Option, PubSub, Runtime, Schema } from 'effect'
|
|
2
2
|
import { type Manifest, definitionClass } from '../definition/definition'
|
|
3
|
-
import { resolveScheduler } from '../internal/scheduler'
|
|
3
|
+
import { resolveScheduler, withInlineNotifications } from '../internal/scheduler'
|
|
4
4
|
import { Bus, publish, type Tagged } from '../runtime/bus'
|
|
5
|
+
import { applyReducers, Reducers } from '../runtime/loop'
|
|
5
6
|
import type { Source } from '../state/token'
|
|
6
7
|
import type { Trigger } from '../ui/trigger'
|
|
7
8
|
|
|
@@ -52,11 +53,39 @@ export const make = <const N extends string, P, Encoded>(
|
|
|
52
53
|
// (e.g. a real Option, whose _tag lives on the prototype).
|
|
53
54
|
const validatePayload = Schema.validateSync(schema)
|
|
54
55
|
const buildUnknown = (payload: unknown): Tagged => build(validatePayload(payload))
|
|
56
|
+
// A trigger is the UI's edge of the loop: it runs inside a discrete DOM event, and
|
|
57
|
+
// React decides what the DOM should look like before that event finishes. Deferring
|
|
58
|
+
// the reducer to the drain fiber means the props React reads back are one turn stale,
|
|
59
|
+
// which is what rewrites a controlled input's value and drops the caret to the end.
|
|
60
|
+
// So reducers for this one event run here, before the trigger returns, and the
|
|
61
|
+
// envelope goes out marked `reduced` for the channel/procedure half. Every batched
|
|
62
|
+
// path — `Event.dispatch`, procedure fan-out, streams — is untouched and still
|
|
63
|
+
// coalesces on the drain loop.
|
|
55
64
|
const trigger = Effect.gen(function* () {
|
|
56
65
|
const bus = yield* Bus
|
|
66
|
+
// Optional: a bus can be provided without a reducer registry (bare harnesses,
|
|
67
|
+
// some test layers), and those keep the original deferred behaviour.
|
|
68
|
+
const reducers = yield* Effect.serviceOption(Reducers)
|
|
57
69
|
const runtime = yield* Effect.runtime<never>()
|
|
58
70
|
return (payload: P) => {
|
|
59
|
-
|
|
71
|
+
const built = build(payload)
|
|
72
|
+
const reduced = Option.match(reducers, {
|
|
73
|
+
onNone: () => false,
|
|
74
|
+
onSome: (registry) =>
|
|
75
|
+
withInlineNotifications(() => {
|
|
76
|
+
const failures = applyReducers({ registry, event: built })
|
|
77
|
+
failures.forEach((failure) =>
|
|
78
|
+
Effect.runSync(
|
|
79
|
+
Effect.logError(
|
|
80
|
+
`reform: reducer threw handling '${failure.event._tag}'`,
|
|
81
|
+
failure.error,
|
|
82
|
+
),
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
return true
|
|
86
|
+
}),
|
|
87
|
+
})
|
|
88
|
+
Runtime.runSync(runtime)(PubSub.publish(bus, { priority: 'High', event: built, reduced }))
|
|
60
89
|
}
|
|
61
90
|
})
|
|
62
91
|
const event: EventClass<N, P> = definitionClass<EventClass<N, P>>({
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Effect, Layer, MutableRef, Schema as S } from 'effect'
|
|
2
|
+
import { it } from '@effect/vitest'
|
|
3
|
+
import { describe, expect } from 'vitest'
|
|
4
|
+
import { until } from '../testkit/flight.testkit'
|
|
5
|
+
import { Engine } from '../runtime/loop'
|
|
6
|
+
import { publish } from '../runtime/bus'
|
|
7
|
+
import * as Event from './event'
|
|
8
|
+
import * as Reducer from '../reducer/reducer'
|
|
9
|
+
import * as State from '../state/state'
|
|
10
|
+
|
|
11
|
+
class Counter extends State.make('sync-trigger-counter', S.Number) {}
|
|
12
|
+
class Bumped extends Event.make('SyncTriggerBumped', S.Struct({ to: S.Number })) {}
|
|
13
|
+
class Fold extends Reducer.make('SyncTriggerFold', {
|
|
14
|
+
states: [Counter],
|
|
15
|
+
events: [Bumped],
|
|
16
|
+
}) {}
|
|
17
|
+
|
|
18
|
+
const makeLayer = (runs: MutableRef.MutableRef<number>) =>
|
|
19
|
+
Reducer.live(Fold, (_current: number, event: { readonly to: number }) => {
|
|
20
|
+
MutableRef.set(runs, MutableRef.get(runs) + 1)
|
|
21
|
+
return event.to
|
|
22
|
+
}).pipe(Layer.provideMerge(Layer.mergeAll(State.live(Counter, 0), Engine)))
|
|
23
|
+
|
|
24
|
+
describe('synchronous trigger path', () => {
|
|
25
|
+
it.live('applies a reducer exactly once — the drain must not re-apply it', () => {
|
|
26
|
+
const runs = MutableRef.make(0)
|
|
27
|
+
return Effect.gen(function* () {
|
|
28
|
+
const bump = yield* Event.trigger(Bumped)
|
|
29
|
+
const store = yield* Counter.store
|
|
30
|
+
|
|
31
|
+
yield* Effect.sync(() => bump({ to: 7 }))
|
|
32
|
+
|
|
33
|
+
// Already applied before the trigger returned, with no turn of the loop.
|
|
34
|
+
expect(MutableRef.get(runs)).toBe(1)
|
|
35
|
+
expect(store.get()).toBe(7)
|
|
36
|
+
|
|
37
|
+
// The envelope still reaches the drain for channels and procedures; give it
|
|
38
|
+
// several turns and prove the reducer was not run a second time.
|
|
39
|
+
yield* until(
|
|
40
|
+
() => MutableRef.get(runs),
|
|
41
|
+
(count) => count > 1,
|
|
42
|
+
20,
|
|
43
|
+
)
|
|
44
|
+
expect(MutableRef.get(runs)).toBe(1)
|
|
45
|
+
expect(store.get()).toBe(7)
|
|
46
|
+
}).pipe(Effect.provide(makeLayer(runs)))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it.live('leaves Event.dispatch on the batched drain', () => {
|
|
50
|
+
const runs = MutableRef.make(0)
|
|
51
|
+
return Effect.gen(function* () {
|
|
52
|
+
const store = yield* Counter.store
|
|
53
|
+
|
|
54
|
+
yield* publish('Normal', Event.construct(Bumped, { to: 3 }))
|
|
55
|
+
|
|
56
|
+
// Nothing synchronous about this path: it is still the drain fiber's job.
|
|
57
|
+
expect(MutableRef.get(runs)).toBe(0)
|
|
58
|
+
|
|
59
|
+
yield* until(
|
|
60
|
+
() => store.get(),
|
|
61
|
+
(value) => value === 3,
|
|
62
|
+
)
|
|
63
|
+
expect(MutableRef.get(runs)).toBe(1)
|
|
64
|
+
}).pipe(Effect.provide(makeLayer(runs)))
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { MutableHashMap, Option, Predicate } from 'effect'
|
|
2
|
+
|
|
3
|
+
export interface KeyCanonicalizer<K> {
|
|
4
|
+
readonly canonical: (key: K) => K
|
|
5
|
+
readonly release: (key: K) => void
|
|
6
|
+
readonly reset: () => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A family indexes its members in a plain `Map`, which compares keys by reference. A
|
|
11
|
+
* composite key — `S.Data(S.Struct({ board, row }))`, the value-typed key Effect makes
|
|
12
|
+
* canonical — is decoded fresh on every lookup, so a plain `Map` mints a new member per
|
|
13
|
+
* lookup and strands the one before it: the fold reads its seed forever and the family
|
|
14
|
+
* grows without bound. Everything else in reform compares values with `Equal.equals`;
|
|
15
|
+
* this makes family keys agree. Primitive keys are their own canonical form and never
|
|
16
|
+
* touch the table, so the hot path is unchanged.
|
|
17
|
+
*/
|
|
18
|
+
export const makeKeyCanonicalizer = <K>(): KeyCanonicalizer<K> => {
|
|
19
|
+
const table = MutableHashMap.empty<K, K>()
|
|
20
|
+
return {
|
|
21
|
+
canonical: (key) => {
|
|
22
|
+
if (!Predicate.isObject(key)) {
|
|
23
|
+
return key
|
|
24
|
+
}
|
|
25
|
+
const known = MutableHashMap.get(table, key)
|
|
26
|
+
if (Option.isSome(known)) {
|
|
27
|
+
return known.value
|
|
28
|
+
}
|
|
29
|
+
MutableHashMap.set(table, key, key)
|
|
30
|
+
return key
|
|
31
|
+
},
|
|
32
|
+
release: (key) => {
|
|
33
|
+
if (Predicate.isObject(key)) {
|
|
34
|
+
MutableHashMap.remove(table, key)
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
reset: () => {
|
|
38
|
+
MutableHashMap.clear(table)
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from 'vitest'
|
|
2
|
+
import { microtask, operationId } from './platform'
|
|
3
|
+
|
|
4
|
+
// A host without them is the case these exist for, and it is not reachable by
|
|
5
|
+
// reading the code: on every runtime the suite runs on, both globals are there
|
|
6
|
+
// and the fallback branch never executes. Removing them is the only way to
|
|
7
|
+
// measure it.
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.unstubAllGlobals()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
describe('microtask', () => {
|
|
13
|
+
test('runs the callback where the host has queueMicrotask', async () => {
|
|
14
|
+
const ran = await new Promise<boolean>((resolve) => {
|
|
15
|
+
microtask(() => resolve(true))
|
|
16
|
+
})
|
|
17
|
+
expect(ran).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('runs the callback where the host has none', async () => {
|
|
21
|
+
vi.stubGlobal('queueMicrotask', undefined)
|
|
22
|
+
const ran = await new Promise<boolean>((resolve) => {
|
|
23
|
+
microtask(() => resolve(true))
|
|
24
|
+
})
|
|
25
|
+
expect(ran).toBe(true)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('does not run the callback synchronously', async () => {
|
|
29
|
+
vi.stubGlobal('queueMicrotask', undefined)
|
|
30
|
+
const order: string[] = []
|
|
31
|
+
microtask(() => order.push('ran'))
|
|
32
|
+
|
|
33
|
+
// The whole of what "microtask" claims, stated without a timer: not during
|
|
34
|
+
// this turn, and by the time a couple of already-resolved promises have
|
|
35
|
+
// been awaited. A `setTimeout` comparison would say the same thing and is
|
|
36
|
+
// banned in tests here, because it settles on the host clock.
|
|
37
|
+
expect(order).toEqual([])
|
|
38
|
+
await Promise.resolve()
|
|
39
|
+
await Promise.resolve()
|
|
40
|
+
expect(order).toEqual(['ran'])
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('operationId', () => {
|
|
45
|
+
test('mints ids that differ where the host has crypto', () => {
|
|
46
|
+
expect(new Set([operationId(), operationId(), operationId()]).size).toBe(3)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('mints ids that differ where the host has none', () => {
|
|
50
|
+
vi.stubGlobal('crypto', undefined)
|
|
51
|
+
// Same millisecond by construction, so the counter is the only thing
|
|
52
|
+
// separating these three and the assertion is about the counter.
|
|
53
|
+
expect(new Set([operationId(), operationId(), operationId()]).size).toBe(3)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { MutableRef } from 'effect'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two host globals reform reaches for, behind a check.
|
|
5
|
+
*
|
|
6
|
+
* The core is renderer-neutral and the React package peers on `react` alone, so
|
|
7
|
+
* a scene is expected to run wherever a React reconciler does — including hosts
|
|
8
|
+
* that are neither a browser nor Node. Two globals were reached for directly and
|
|
9
|
+
* neither is guaranteed off the web: measured on `@react-gpui/core`, whose
|
|
10
|
+
* deno_core host installs `console` and the timers and nothing else, a scene
|
|
11
|
+
* dies at the first scheduler tick with `queueMicrotask is not defined`.
|
|
12
|
+
*
|
|
13
|
+
* Both fall back rather than throw, because the property each is used for
|
|
14
|
+
* survives the fallback: a resolved promise is a microtask by definition, and an
|
|
15
|
+
* operation id has to be unique among the ids one runtime mints.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run `callback` on a microtask.
|
|
20
|
+
*
|
|
21
|
+
* `queueMicrotask` where the host has it, a resolved promise where it does not.
|
|
22
|
+
* The two differ in one way that matters, which is why the check is kept rather
|
|
23
|
+
* than always taking the promise: a callback that throws inside `queueMicrotask`
|
|
24
|
+
* reaches the host's global error handler, where the same callback inside
|
|
25
|
+
* `.then` becomes an unhandled rejection nobody sees. The rethrow below puts the
|
|
26
|
+
* fallback back on the first of those.
|
|
27
|
+
*/
|
|
28
|
+
export const microtask = (callback: () => void): void => {
|
|
29
|
+
if (typeof queueMicrotask === 'function') {
|
|
30
|
+
queueMicrotask(callback)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
// oxlint-disable-next-line reform-rules/no-promise-static-methods, reform-rules/no-promise-methods -- a microtask is the primitive being polyfilled here; Effect.succeed is not one, and this branch exists precisely because the host has no scheduling primitive to reach for
|
|
34
|
+
void Promise.resolve()
|
|
35
|
+
.then(callback)
|
|
36
|
+
.catch((error: unknown) => {
|
|
37
|
+
// oxlint-disable-next-line reform-rules/no-set-timeout-interval -- rethrowing on a macrotask is what reaches the host's global error handler, which is the semantics `queueMicrotask` has and a rejected promise does not
|
|
38
|
+
setTimeout(() => {
|
|
39
|
+
// oxlint-disable-next-line reform-rules/no-throw -- only way to surface a listener defect to the host's global error handler, as internal/scheduler.ts does for the same reason
|
|
40
|
+
throw error
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const RADIX = 36
|
|
46
|
+
|
|
47
|
+
const minted = MutableRef.make(0)
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* An id no other operation of this runtime shares.
|
|
51
|
+
*
|
|
52
|
+
* `crypto.randomUUID` where the host has it, because an id that leaves the
|
|
53
|
+
* process should not be guessable from another one. Where it does not, the
|
|
54
|
+
* COUNTER carries uniqueness — every id this runtime mints differs from every
|
|
55
|
+
* other, which is the whole of what a `queued`/`settled` pairing needs — and the
|
|
56
|
+
* clock only separates two runtimes started at different milliseconds. Two
|
|
57
|
+
* started inside one millisecond can agree, so a host that sends these ids
|
|
58
|
+
* somewhere they meet another runtime's should install `crypto.randomUUID`
|
|
59
|
+
* rather than rely on this.
|
|
60
|
+
*
|
|
61
|
+
* `Math.random` is deliberately absent and should not be added: this package
|
|
62
|
+
* forbids it in favour of Effect's `Random`, and a service cannot be reached
|
|
63
|
+
* from a function the scheduler calls synchronously.
|
|
64
|
+
*/
|
|
65
|
+
export const operationId = (): string => {
|
|
66
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
67
|
+
return crypto.randomUUID()
|
|
68
|
+
}
|
|
69
|
+
MutableRef.update(minted, (count) => count + 1)
|
|
70
|
+
// oxlint-disable-next-line reform-rules/no-date-constructor -- DateTime is an Effect and this mint is synchronous; the clock is only a tiebreak between runtimes here, never read as a time
|
|
71
|
+
return `op-${Date.now().toString(RADIX)}-${MutableRef.get(minted).toString(RADIX)}`
|
|
72
|
+
}
|